klyff 0.1.2

Text rendering library for games with MSDF support
Documentation
struct VertexInput {
    // Mesh-encoder buffer (slot 0)
    @location(0) uvw: vec3f,
    @location(1) screen_pos: vec2f,
    @location(2) font_size_px: f32,
    // Material-encoder buffer (slot 1)
    @location(3) material_index: u32,
    @location(4) gradient_uv: vec2f,
}

struct VertexOutput {
    @builtin(position) @invariant clip_position: vec4f,
    @location(0) uvw: vec3f,
    @location(1) @interpolate(flat) font_size_px: f32,
    @location(2) @interpolate(flat) material_index: u32,
    @location(3) gradient_uv: vec2f,
}

struct Material {
    color: vec4f,
    gradient_color: vec4f,
    gradient_direction: vec2f,
    render_dist_range: vec2f,
    max_alpha_dist_range: vec2f,
    offset: vec2f,
    roundness: f32,
}

struct Uniforms {
    screen_size: vec2f,
}

@group(0) @binding(0) var atlas: texture_2d_array<f32>;
@group(0) @binding(1) var atlas_sampler: sampler;
@group(1) @binding(0) var<uniform> uniforms: Uniforms;
@group(2) @binding(0) var<storage, read> materials: array<Material>;

@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
    var out: VertexOutput;
    let material = materials[in.material_index];
    let pos = in.screen_pos + material.offset;
    let ndc = (pos / uniforms.screen_size) * 2.0 - 1.0;
    out.clip_position = vec4f(ndc.x, -ndc.y, 0.0, 1.0);
    out.uvw = in.uvw;
    out.font_size_px = in.font_size_px;
    out.material_index = in.material_index;
    out.gradient_uv = in.gradient_uv;
    return out;
}

fn median(r: f32, g: f32, b: f32) -> f32 {
    return max(min(r, g), min(max(r, g), b));
}

fn sample_atlas(uvw: vec3f) -> vec4f {
    return textureSample(atlas, atlas_sampler, uvw.xy, i32(uvw.z));
}

// Convert sampled color (0-1) to signed distance in screen pixels.
fn color_to_dist(color: f32, font_size_px: f32) -> f32 {
    return (color - 0.5) * font_size_px;
}

// Alpha for a fragment at signed screen-space distance `dist`.
//
//   render_dist_range [rlo, rhi]:
//     The material is non-zero only within this range (in screen pixels).
//
//   max_alpha_dist_range [mlo, mhi]:
//     Within [mlo, mhi] the alpha reaches its maximum value (1.0).
//     Between rlo→mlo and mhi→rhi the alpha fades linearly to zero.
fn msdf_coverage(dist: f32, material: Material) -> f32 {
    let rlo = material.render_dist_range.x;
    let rhi = material.render_dist_range.y;
    let mlo = material.max_alpha_dist_range.x;
    let mhi = material.max_alpha_dist_range.y;

    // Each edge: linear ramp between the render boundary and the max-alpha boundary.
    // When rlo==mlo or mhi==rhi there is no ramp; 1e-6 avoids division by zero while
    // keeping the result a step function at that boundary.
    let lower = clamp((dist - rlo) / max(mlo - rlo, 1e-6), 0.0, 1.0);
    let upper = clamp((rhi - dist) / max(rhi - mhi, 1e-6), 0.0, 1.0);
    return min(lower, upper);
}

fn material_color(material: Material, gradient_uv: vec2f) -> vec4f {
    let intensity = dot(gradient_uv, material.gradient_direction);
    return mix(material.color, material.gradient_color, intensity);
}

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4f {
    let material = materials[in.material_index];

    let s = sample_atlas(in.uvw);
    let msdf = mix(median(s.r, s.g, s.b), s.a, material.roundness);
    let dist = color_to_dist(msdf, in.font_size_px);
    let alpha = msdf_coverage(dist, material);
    var col = material_color(material, in.gradient_uv);
    col.a = col.a * alpha;
    return col;
}