use std::collections::HashMap;
use bytemuck::{Pod, Zeroable};
use oxitext_sdf::SdfTile;
use oxiui_core::UiError;
use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
AddressMode, BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayout,
BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingResource, BindingType, BlendComponent,
BlendFactor, BlendOperation, BlendState, ColorTargetState, ColorWrites, Device, Extent3d,
FilterMode, FragmentState, FrontFace, IndexFormat, MipmapFilterMode, MultisampleState,
PipelineLayoutDescriptor, PolygonMode, PrimitiveState, PrimitiveTopology, Queue, RenderPass,
RenderPipeline, RenderPipelineDescriptor, Sampler, SamplerBindingType, SamplerDescriptor,
ShaderModuleDescriptor, ShaderSource, ShaderStages, TexelCopyBufferLayout,
TexelCopyTextureInfo, Texture, TextureAspect, TextureDescriptor, TextureDimension,
TextureFormat, TextureSampleType, TextureUsages, TextureView, TextureViewDescriptor,
TextureViewDimension, VertexAttribute, VertexBufferLayout, VertexFormat, VertexState,
VertexStepMode,
};
const SDF_TEXT_SHADER_WGSL: &str = r#"
// ─── Uniforms ────────────────────────────────────────────────────────────────
struct SdfUniforms {
// Softness of the SDF edge (typical range: 0.03 – 0.10).
edge_softness: f32,
// Padding to meet minimum 16-byte alignment.
_pad0: f32,
_pad1: f32,
_pad2: f32,
};
@group(0) @binding(0) var<uniform> sdf_uniforms: SdfUniforms;
@group(0) @binding(1) var t_sdf: texture_2d<f32>;
@group(0) @binding(2) var s_sdf: sampler;
// ─── Vertex ───────────────────────────────────────────────────────────────────
struct VertexIn {
@location(0) position: vec2<f32>, // clip-space X, Y (already [-1, 1])
@location(1) uv: vec2<f32>, // atlas UV in [0, 1]
@location(2) color: vec4<f32>, // premultiplied RGBA
};
struct VertexOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
};
@vertex
fn vs_main(in: VertexIn) -> VertexOut {
var out: VertexOut;
out.clip_pos = vec4<f32>(in.position, 0.0, 1.0);
out.uv = in.uv;
out.color = in.color;
return out;
}
// ─── Fragment ─────────────────────────────────────────────────────────────────
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
// Sample the SDF atlas — R channel holds the distance in [0, 1].
// Value 0.5 is the outline edge; values > 0.5 are inside the glyph.
let d = textureSample(t_sdf, s_sdf, in.uv).r;
let half = 0.5;
let softness = sdf_uniforms.edge_softness;
// Coverage: smooth transition from fully-transparent to fully-opaque.
let coverage = smoothstep(half - softness, half + softness, d);
// Premultiply: output = color * coverage (alpha already baked in color.a).
let alpha = coverage * in.color.a;
return vec4<f32>(in.color.rgb * alpha, alpha);
}
"#;
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
#[repr(C)]
pub struct SdfVertex {
pub position: [f32; 2],
pub uv: [f32; 2],
pub color: [f32; 4],
}
#[derive(Clone, Debug)]
pub struct SdfTextConfig {
pub atlas_size: u32,
pub edge_softness: f32,
}
impl Default for SdfTextConfig {
fn default() -> Self {
SdfTextConfig {
atlas_size: 1024,
edge_softness: 0.06,
}
}
}
struct ShelfPacker {
atlas_size: u32,
cursor_x: u32,
cursor_y: u32,
shelf_height: u32,
}
impl ShelfPacker {
fn new(atlas_size: u32) -> Self {
ShelfPacker {
atlas_size,
cursor_x: 0,
cursor_y: 0,
shelf_height: 0,
}
}
fn allocate(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
if w > self.atlas_size || h > self.atlas_size {
return None;
}
if self.cursor_x + w <= self.atlas_size {
let x = self.cursor_x;
let y = self.cursor_y;
self.cursor_x += w;
self.shelf_height = self.shelf_height.max(h);
return Some((x, y));
}
let new_y = self.cursor_y + self.shelf_height;
if new_y + h > self.atlas_size {
return None; }
let x = 0;
self.cursor_x = w;
self.cursor_y = new_y;
self.shelf_height = h;
Some((x, new_y))
}
}
#[derive(Clone, Debug)]
pub struct AtlasEntry {
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
pub bearing_x: i32,
pub bearing_y: i32,
pub advance_x: f32,
pub width_px: u32,
pub height_px: u32,
}
pub struct SdfTextPipeline {
pipeline: RenderPipeline,
bind_group: BindGroup,
atlas_texture: Texture,
_atlas_view: TextureView,
_uniform_buffer: wgpu::Buffer,
packer: ShelfPacker,
entries: HashMap<u16, AtlasEntry>,
atlas_size: u32,
}
impl SdfTextPipeline {
pub fn new(device: &Device, surface_format: TextureFormat, config: SdfTextConfig) -> Self {
let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("sdf_text_shader"),
source: ShaderSource::Wgsl(SDF_TEXT_SHADER_WGSL.into()),
});
let atlas_texture = device.create_texture(&TextureDescriptor {
label: Some("sdf_atlas"),
size: Extent3d {
width: config.atlas_size,
height: config.atlas_size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::R8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
});
let atlas_view = atlas_texture.create_view(&TextureViewDescriptor::default());
let sampler = device.create_sampler(&SamplerDescriptor {
label: Some("sdf_text_sampler"),
address_mode_u: AddressMode::ClampToEdge,
address_mode_v: AddressMode::ClampToEdge,
address_mode_w: AddressMode::ClampToEdge,
mag_filter: FilterMode::Linear,
min_filter: FilterMode::Linear,
mipmap_filter: MipmapFilterMode::Nearest,
..Default::default()
});
let uniform_data: [f32; 4] = [config.edge_softness, 0.0, 0.0, 0.0];
let uniform_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("sdf_uniforms"),
contents: bytemuck::cast_slice(&uniform_data),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
label: Some("sdf_text_bgl"),
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: true },
view_dimension: TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::Filtering),
count: None,
},
],
});
let bind_group = Self::build_bind_group(
device,
&bind_group_layout,
&uniform_buffer,
&atlas_view,
&sampler,
);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("sdf_text_pipeline_layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let vertex_buffer_layout = VertexBufferLayout {
array_stride: std::mem::size_of::<SdfVertex>() as u64,
step_mode: VertexStepMode::Vertex,
attributes: &[
VertexAttribute {
format: VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
VertexAttribute {
format: VertexFormat::Float32x2,
offset: std::mem::offset_of!(SdfVertex, uv) as u64,
shader_location: 1,
},
VertexAttribute {
format: VertexFormat::Float32x4,
offset: std::mem::offset_of!(SdfVertex, color) as u64,
shader_location: 2,
},
],
};
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
label: Some("sdf_text_pipeline"),
layout: Some(&pipeline_layout),
vertex: VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[vertex_buffer_layout],
},
primitive: PrimitiveState {
topology: PrimitiveTopology::TriangleList,
front_face: FrontFace::Ccw,
polygon_mode: PolygonMode::Fill,
..Default::default()
},
depth_stencil: None,
multisample: MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(ColorTargetState {
format: surface_format,
blend: Some(BlendState {
color: BlendComponent {
src_factor: BlendFactor::One,
dst_factor: BlendFactor::OneMinusSrcAlpha,
operation: BlendOperation::Add,
},
alpha: BlendComponent {
src_factor: BlendFactor::One,
dst_factor: BlendFactor::OneMinusSrcAlpha,
operation: BlendOperation::Add,
},
}),
write_mask: ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
SdfTextPipeline {
pipeline,
bind_group,
atlas_texture,
_atlas_view: atlas_view,
_uniform_buffer: uniform_buffer,
packer: ShelfPacker::new(config.atlas_size),
entries: HashMap::new(),
atlas_size: config.atlas_size,
}
}
pub fn upload_glyph(
&mut self,
_device: &Device,
queue: &Queue,
tile: &SdfTile,
) -> Result<(), UiError> {
if tile.width == 0 || tile.height == 0 {
return Err(UiError::Render("upload_glyph: zero-size tile".to_string()));
}
let (ax, ay) = self
.packer
.allocate(tile.width, tile.height)
.ok_or_else(|| {
UiError::Render(format!(
"SDF atlas full — cannot fit glyph {} ({}×{})",
tile.glyph_id, tile.width, tile.height
))
})?;
queue.write_texture(
TexelCopyTextureInfo {
texture: &self.atlas_texture,
mip_level: 0,
origin: wgpu::Origin3d { x: ax, y: ay, z: 0 },
aspect: TextureAspect::All,
},
&tile.data,
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(tile.width),
rows_per_image: Some(tile.height),
},
Extent3d {
width: tile.width,
height: tile.height,
depth_or_array_layers: 1,
},
);
let inv = self.atlas_size as f32;
let entry = AtlasEntry {
uv_min: [ax as f32 / inv, ay as f32 / inv],
uv_max: [
(ax + tile.width) as f32 / inv,
(ay + tile.height) as f32 / inv,
],
bearing_x: tile.bearing_x,
bearing_y: tile.bearing_y,
advance_x: tile.advance_x,
width_px: tile.width,
height_px: tile.height,
};
self.entries.insert(tile.glyph_id, entry);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn draw_text(
&self,
device: &Device,
render_pass: &mut RenderPass<'_>,
glyphs: &[(u16, f32, f32)],
color: [f32; 4],
viewport_w: f32,
viewport_h: f32,
) -> Result<(), UiError> {
if glyphs.is_empty() {
return Ok(());
}
let mut vertices: Vec<SdfVertex> = Vec::with_capacity(glyphs.len() * 4);
let mut indices: Vec<u32> = Vec::with_capacity(glyphs.len() * 6);
let mut quad_count: u32 = 0;
for &(glyph_id, pen_x, pen_y) in glyphs {
let entry = match self.entries.get(&glyph_id) {
Some(e) => e,
None => continue, };
let w_clip = entry.width_px as f32 / viewport_w * 2.0;
let h_clip = entry.height_px as f32 / viewport_h * 2.0;
let bx_clip = entry.bearing_x as f32 / viewport_w * 2.0;
let by_clip = entry.bearing_y as f32 / viewport_h * 2.0;
let x0 = pen_x + bx_clip;
let y0 = pen_y - by_clip; let x1 = x0 + w_clip;
let y1 = y0 - h_clip;
let [u0, v0] = entry.uv_min;
let [u1, v1] = entry.uv_max;
let base = quad_count * 4;
vertices.extend_from_slice(&[
SdfVertex {
position: [x0, y0],
uv: [u0, v0],
color,
},
SdfVertex {
position: [x1, y0],
uv: [u1, v0],
color,
},
SdfVertex {
position: [x1, y1],
uv: [u1, v1],
color,
},
SdfVertex {
position: [x0, y1],
uv: [u0, v1],
color,
},
]);
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
quad_count += 1;
}
if quad_count == 0 {
return Ok(()); }
let vbuf = device.create_buffer_init(&BufferInitDescriptor {
label: Some("sdf_text_vbuf"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let ibuf = device.create_buffer_init(&BufferInitDescriptor {
label: Some("sdf_text_ibuf"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &self.bind_group, &[]);
render_pass.set_vertex_buffer(0, vbuf.slice(..));
render_pass.set_index_buffer(ibuf.slice(..), IndexFormat::Uint32);
render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1);
Ok(())
}
pub fn glyph_count(&self) -> usize {
self.entries.len()
}
pub fn entry(&self, glyph_id: u16) -> Option<&AtlasEntry> {
self.entries.get(&glyph_id)
}
fn build_bind_group(
device: &Device,
layout: &BindGroupLayout,
uniform_buffer: &wgpu::Buffer,
atlas_view: &TextureView,
sampler: &Sampler,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
label: Some("sdf_text_bg"),
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::TextureView(atlas_view),
},
BindGroupEntry {
binding: 2,
resource: BindingResource::Sampler(sampler),
},
],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shelf_packer_simple_allocation() {
let mut packer = ShelfPacker::new(512);
let r1 = packer.allocate(32, 32);
assert_eq!(r1, Some((0, 0)));
let r2 = packer.allocate(32, 32);
assert_eq!(r2, Some((32, 0)));
}
#[test]
fn shelf_packer_new_shelf_when_row_full() {
let mut packer = ShelfPacker::new(64);
let _ = packer.allocate(32, 32); let _ = packer.allocate(32, 32); let r3 = packer.allocate(32, 16); assert_eq!(r3, Some((0, 32)));
}
#[test]
fn shelf_packer_atlas_full_returns_none() {
let mut packer = ShelfPacker::new(64);
let _ = packer.allocate(64, 64); let r = packer.allocate(1, 1);
assert!(r.is_none());
}
#[test]
fn shelf_packer_oversized_tile_returns_none() {
let mut packer = ShelfPacker::new(64);
assert!(packer.allocate(128, 32).is_none());
assert!(packer.allocate(32, 128).is_none());
}
#[test]
fn sdf_text_config_default() {
let cfg = SdfTextConfig::default();
assert_eq!(cfg.atlas_size, 1024);
assert!((cfg.edge_softness - 0.06).abs() < 1e-6);
}
#[test]
fn atlas_entry_fields() {
let entry = AtlasEntry {
uv_min: [0.0, 0.0],
uv_max: [0.5, 0.5],
bearing_x: -1,
bearing_y: 12,
advance_x: 14.0,
width_px: 16,
height_px: 16,
};
assert_eq!(entry.width_px, 16);
assert_eq!(entry.advance_x, 14.0);
}
#[test]
fn sdf_vertex_is_pod() {
let v = SdfVertex {
position: [0.0, 0.0],
uv: [0.5, 0.5],
color: [1.0, 1.0, 1.0, 1.0],
};
let _bytes: &[u8] = bytemuck::bytes_of(&v);
}
}