klyff_msdf 0.1.3

MSDF generation library with optional GPU acceleration.
Documentation
#![cfg(feature = "wgpu")]
#![allow(dead_code)]

use super::compare::{LumaImageBuffer, compare_image_with_threshold};
use super::{dist_to_color, dump_image, to_dyn_image};
use klyff_msdf::{
    AtlasRegionSize, GpuAtlasRegion, MsdfGenerator, MtsdfGpuWriter, OutlineProvider,
    SkrifaOutlineProvider, median,
};
use skrifa::MetadataProvider;

pub struct GpuCtx {
    pub device: wgpu::Device,
    pub queue: wgpu::Queue,
}

impl GpuCtx {
    pub fn new() -> Option<Self> {
        let instance = wgpu::Instance::default();
        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
            power_preference: wgpu::PowerPreference::HighPerformance,
            force_fallback_adapter: false,
            compatible_surface: None,
        }))
        .ok()?;
        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
            label: Some("klyff_msdf test device"),
            required_features: wgpu::Features::empty(),
            required_limits: wgpu::Limits::default(),
            memory_hints: wgpu::MemoryHints::Performance,
            trace: wgpu::Trace::Off,
            experimental_features: wgpu::ExperimentalFeatures::disabled(),
        }))
        .ok()?;
        Some(Self { device, queue })
    }
}

pub fn compare_font_render_gpu(
    gpu: &GpuCtx,
    writer: &mut MtsdfGpuWriter,
    name: &str,
    font_data: &[u8],
    character: char,
    ref_img_path: &std::path::Path,
    output: &std::path::Path,
) {
    let font_ref = skrifa::FontRef::new(font_data).expect("Can read font");
    let glyph_id = font_ref
        .charmap()
        .map(character)
        .unwrap_or_else(|| panic!("{character} is not valid character in font"));
    let skrifa_provider = SkrifaOutlineProvider::new(font_ref.clone(), glyph_id);

    const PPEM: f32 = 100.;
    const PADDING_X: usize = 10;
    const PADDING_Y: usize = 10;

    let mut generator = MsdfGenerator::new();
    let em_size = skrifa_provider.length_per_em();

    let outline = match generator.read_glyph(&skrifa_provider) {
        Ok(outline) => outline,
        Err(klyff_msdf::ReadGlyphOutlineError::EmptyGlyph)
        | Err(klyff_msdf::ReadGlyphOutlineError::ShouldRasterize) => {
            return;
        }
        Err(klyff_msdf::ReadGlyphOutlineError::NotFound) => {
            panic!("Glyph not found for {glyph_id}");
        }
        Err(klyff_msdf::ReadGlyphOutlineError::Other(e)) => {
            panic!("Error encountered with glyph {glyph_id}: {e}");
        }
    };

    let size = AtlasRegionSize {
        inner_width: (outline.width_em() * PPEM).round() as usize,
        inner_height: (outline.height_em() * PPEM).round() as usize,
        padding_x: PADDING_X,
        padding_y: PADDING_Y,
    };
    let width = size.total_width();
    let height = size.total_height();
    let inner_width = size.inner_width;
    let inner_height = size.inner_height;

    let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
        label: Some("klyff_msdf test atlas"),
        size: wgpu::Extent3d {
            width: width as u32,
            height: height as u32,
            depth_or_array_layers: 1,
        },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format: wgpu::TextureFormat::Rgba8Unorm,
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
            | wgpu::TextureUsages::COPY_SRC
            | wgpu::TextureUsages::TEXTURE_BINDING,
        view_formats: &[],
    });

    let region = GpuAtlasRegion {
        layer: 0,
        min_x: 0,
        min_y: 0,
        width: width as u32,
        height: height as u32,
        padding_x: PADDING_X as u32,
        padding_y: PADDING_Y as u32,
    };
    writer.add_glyph(outline, region);

    // Pad row stride to 256 bytes for COPY_BYTES_PER_ROW_ALIGNMENT.
    let bytes_per_pixel = 4u32;
    let unaligned = width as u32 * bytes_per_pixel;
    let padded_bytes_per_row = (unaligned + 255) & !255;
    let readback_size = padded_bytes_per_row as u64 * height as u64;
    let readback = gpu.device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("klyff_msdf test readback"),
        size: readback_size,
        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
        mapped_at_creation: false,
    });

    let mut encoder = gpu
        .device
        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("klyff_msdf test encoder"),
        });
    writer.write_glyphs(&gpu.device, &gpu.queue, &mut encoder, &texture);
    encoder.copy_texture_to_buffer(
        wgpu::TexelCopyTextureInfo {
            texture: &texture,
            mip_level: 0,
            origin: wgpu::Origin3d::ZERO,
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::TexelCopyBufferInfo {
            buffer: &readback,
            layout: wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(padded_bytes_per_row),
                rows_per_image: Some(height as u32),
            },
        },
        wgpu::Extent3d {
            width: width as u32,
            height: height as u32,
            depth_or_array_layers: 1,
        },
    );
    gpu.queue.submit([encoder.finish()]);

    let slice = readback.slice(..);
    slice.map_async(wgpu::MapMode::Read, |_| {});
    gpu.device
        .poll(wgpu::PollType::wait_indefinitely())
        .unwrap();
    let mapped = slice.get_mapped_range();
    let mut tight = vec![0u8; width * height * 4];
    for y in 0..height {
        let src = y * padded_bytes_per_row as usize;
        let dst = y * width * 4;
        tight[dst..dst + width * 4].copy_from_slice(&mapped[src..src + width * 4]);
    }
    drop(mapped);
    readback.unmap();

    let mtsdf_source_image =
        image::RgbaImage::from_raw(width as u32, height as u32, tight).expect("Valid image data");
    assert_ne!(mtsdf_source_image.width(), 0);
    assert_ne!(mtsdf_source_image.height(), 0);

    let (mtsdf_pseudo_content, mtsdf_true_content) = {
        let mut pseudo = vec![0; inner_width * inner_height];
        let mut true_dist = vec![0; inner_width * inner_height];
        for (j, y) in (size.padding_y..size.padding_y + inner_height).enumerate() {
            for (i, x) in (size.padding_x..size.padding_x + inner_width).enumerate() {
                let [r, g, b, a] = image::imageops::sample_bilinear(
                    &mtsdf_source_image,
                    (x as f32 + 0.5) / (width as f32),
                    (y as f32 + 0.5) / (height as f32),
                )
                .expect("Valid pixel")
                .0;
                let m = median(r, g, b);

                pseudo[j * inner_width + i] = dist_to_color(m, em_size);
                true_dist[j * inner_width + i] = dist_to_color(a, em_size);
            }
        }
        (pseudo, true_dist)
    };

    let w = inner_width as u32;
    let h = inner_height as u32;
    let mtsdf_pseudo_cmp_image = LumaImageBuffer {
        width: inner_width,
        height: inner_height,
        buffer: &mtsdf_pseudo_content,
    };
    let mtsdf_true_cmp_image = LumaImageBuffer {
        width: inner_width,
        height: inner_height,
        buffer: &mtsdf_true_content,
    };

    let Some(ref_image) = std::fs::File::open(ref_img_path)
        .map(std::io::BufReader::new)
        .ok()
        .and_then(|r| image::load(r, image::ImageFormat::Png).ok())
    else {
        println!(
            "No existing reference image at {}. Skipping GPU comparison.",
            ref_img_path.display()
        );
        return;
    };
    let ref_image = LumaImageBuffer {
        width: inner_width,
        height: inner_height,
        buffer: &ref_image
            .resize(w, h, image::imageops::FilterType::Triangle)
            .to_luma8(),
    };

    // GPU uses f32 throughout while the CPU reference path can use f64 for some
    // math, so allow a slightly looser tolerance than the CPU test.
    const GPU_MAX_DEVIATION_RATIO: f32 = 0.05;
    let (mtsdf_pseudo_valid, mtsdf_pseudo_deviation) =
        compare_image_with_threshold(&mtsdf_pseudo_cmp_image, &ref_image, GPU_MAX_DEVIATION_RATIO);
    let (mtsdf_true_valid, mtsdf_true_deviation) =
        compare_image_with_threshold(&mtsdf_true_cmp_image, &ref_image, GPU_MAX_DEVIATION_RATIO);

    if !mtsdf_pseudo_valid || !mtsdf_true_valid {
        dump_image(
            output,
            name,
            glyph_id.to_u32(),
            &[
                image::DynamicImage::ImageRgba8(mtsdf_source_image),
                to_dyn_image(mtsdf_pseudo_content, w, h),
                to_dyn_image(mtsdf_true_content, w, h),
            ],
        );

        panic!(
            "GPU regression with rendering glyph {glyph_id}. Check dumped images at {}.
Deviation ratio: MTSDF Pseudo = {mtsdf_pseudo_deviation}, MTSDF True = {mtsdf_true_deviation}",
            output.display()
        )
    }
}