Skip to main content

khora_infra/renderer/
text.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::renderer::custom::pixel_font;
16use crate::renderer::util::TextureAtlas;
17use khora_core::asset::{font::Font, AssetUUID, Handle};
18use khora_core::math::{LinearRgba, Vec2, Vec4};
19use khora_core::renderer::{
20    api::command::{
21        BindGroupDescriptor, BindGroupEntry, BindGroupId, BindGroupLayoutDescriptor,
22        BindGroupLayoutEntry, BindGroupLayoutId, BindingResource, BindingType, BufferBinding,
23        LoadOp, Operations, RenderPassColorAttachment, RenderPassDescriptor, StoreOp,
24    },
25    api::core::{ShaderModuleDescriptor, ShaderSourceData},
26    api::pipeline::{
27        ColorTargetStateDescriptor, ColorWrites, MultisampleStateDescriptor,
28        PipelineLayoutDescriptor, PrimitiveStateDescriptor, PrimitiveTopology,
29        RenderPipelineDescriptor, RenderPipelineId,
30    },
31    api::resource::{
32        AddressMode, BufferDescriptor, BufferId, BufferUsage, FilterMode, MipmapFilterMode,
33        SamplerDescriptor, SamplerId, TextureViewId,
34    },
35    api::text::{TextLayout, TextRenderer},
36    api::util::{IndexFormat, SampleCount, ShaderStageFlags, TextureFormat},
37    traits::CommandEncoder,
38    GraphicsDevice,
39};
40use std::any::Any;
41use std::borrow::Cow;
42use std::collections::HashMap;
43use std::sync::{Arc, Mutex};
44
45/// Vertex for text rendering.
46#[repr(C)]
47#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
48pub struct TextVertex {
49    /// Position in screen space.
50    pub pos: [f32; 2],
51    /// UV coordinates.
52    pub uv: [f32; 2],
53    /// Vertex color.
54    pub color: [f32; 4],
55}
56
57/// A laid-out block of text.
58pub struct StandardTextLayout {
59    /// Final size of the text block.
60    pub size: Vec2,
61    /// Individual glyph positions.
62    pub glyph_positions: Vec<(u32, Vec2)>,
63    /// Handle to the font.
64    pub font_handle: Handle<Font>,
65    /// UUID of the font asset.
66    pub font_uuid: AssetUUID,
67    /// Rendered font size.
68    pub font_size: f32,
69}
70
71impl TextLayout for StandardTextLayout {
72    fn size(&self) -> Vec2 {
73        self.size
74    }
75
76    fn as_any(&self) -> &dyn Any {
77        self
78    }
79}
80
81/// Dynamic GPU resources for the text renderer.
82struct TextGpuResources {
83    pipeline: RenderPipelineId,
84    #[allow(dead_code)]
85    bind_group_layout: BindGroupLayoutId,
86    #[allow(dead_code)]
87    sampler: SamplerId,
88    atlas: TextureAtlas,
89    bind_group: BindGroupId,
90    vertex_buffer: BufferId,
91    index_buffer: BufferId,
92}
93
94/// A "maison" (home-made) text renderer that is backend-agnostic.
95pub struct StandardTextRenderer {
96    glyph_cache: Mutex<HashMap<(AssetUUID, char, u32), GlyphData>>, // (font_uuid, char, size_fixed)
97    queue: Mutex<Vec<QueuedText>>,
98    gpu_resources: Mutex<Option<TextGpuResources>>,
99    shader_source: String,
100}
101
102struct QueuedText {
103    layout: Arc<StandardTextLayout>,
104    pos: Vec2,
105    color: Vec4,
106}
107
108#[derive(Clone, Copy)]
109struct GlyphData {
110    pub uv_min: Vec2,
111    pub uv_max: Vec2,
112    #[allow(dead_code)]
113    pub size: Vec2,
114}
115
116impl StandardTextRenderer {
117    /// Creates a new StandardTextRenderer with the provided shader source.
118    pub fn new(shader_source: String) -> Self {
119        Self {
120            glyph_cache: Mutex::new(HashMap::new()),
121            queue: Mutex::new(Vec::new()),
122            gpu_resources: Mutex::new(None),
123            shader_source,
124        }
125    }
126
127    fn rasterize_glyph(&self, c: char) -> Option<(u32, u32, Vec<u8>)> {
128        pixel_font::rasterize_glyph(c)
129    }
130
131    fn init_resources(
132        &self,
133        device: &dyn GraphicsDevice,
134    ) -> Result<TextGpuResources, Box<dyn std::error::Error + Send + Sync>> {
135        let atlas_size = 1024;
136
137        // 1. Shader & Layout
138        let shader_module = device.create_shader_module(&ShaderModuleDescriptor {
139            label: Some("text_shader"),
140            source: ShaderSourceData::Wgsl(Cow::Borrowed(&self.shader_source)),
141        })?;
142
143        let bgl = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
144            label: Some("text_bgl"),
145            entries: &[
146                BindGroupLayoutEntry {
147                    binding: 0,
148                    visibility: ShaderStageFlags::VERTEX,
149                    ty: BindingType::Buffer {
150                        ty: khora_core::renderer::api::command::BufferBindingType::Uniform,
151                        has_dynamic_offset: false,
152                        min_binding_size: None,
153                    },
154                },
155                BindGroupLayoutEntry {
156                    binding: 1,
157                    visibility: ShaderStageFlags::FRAGMENT,
158                    ty: BindingType::Texture {
159                        sample_type: khora_core::renderer::api::command::TextureSampleType::Float {
160                            filterable: true,
161                        },
162                        view_dimension:
163                            khora_core::renderer::api::command::TextureViewDimension::D2,
164                        multisampled: false,
165                    },
166                },
167                BindGroupLayoutEntry {
168                    binding: 2,
169                    visibility: ShaderStageFlags::FRAGMENT,
170                    ty: BindingType::Sampler(
171                        khora_core::renderer::api::command::SamplerBindingType::Filtering,
172                    ),
173                },
174            ],
175        })?;
176
177        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
178            label: Some(Cow::Borrowed("text_pipeline_layout")),
179            bind_group_layouts: &[bgl],
180        })?;
181
182        // 2. Pipeline
183        let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
184            label: Some(Cow::Borrowed("text_pipeline")),
185            layout: Some(pipeline_layout),
186            vertex_shader_module: shader_module,
187            vertex_entry_point: Cow::Borrowed("vs_main"),
188            fragment_shader_module: Some(shader_module),
189            fragment_entry_point: Some(Cow::Borrowed("fs_main")),
190            vertex_buffers_layout: Cow::Owned(vec![
191                khora_core::renderer::api::pipeline::VertexBufferLayoutDescriptor {
192                    array_stride: std::mem::size_of::<TextVertex>() as u64,
193                    step_mode: khora_core::renderer::api::pipeline::VertexStepMode::Vertex,
194                    attributes: Cow::Owned(vec![
195                        khora_core::renderer::api::pipeline::VertexAttributeDescriptor {
196                            format: khora_core::renderer::api::pipeline::VertexFormat::Float32x2,
197                            offset: 0,
198                            shader_location: 0,
199                        },
200                        khora_core::renderer::api::pipeline::VertexAttributeDescriptor {
201                            format: khora_core::renderer::api::pipeline::VertexFormat::Float32x2,
202                            offset: 8,
203                            shader_location: 1,
204                        },
205                        khora_core::renderer::api::pipeline::VertexAttributeDescriptor {
206                            format: khora_core::renderer::api::pipeline::VertexFormat::Float32x4,
207                            offset: 16,
208                            shader_location: 2,
209                        },
210                    ]),
211                },
212            ]),
213            primitive_state: PrimitiveStateDescriptor {
214                topology: PrimitiveTopology::TriangleList,
215                ..Default::default()
216            },
217            depth_stencil_state: None,
218            color_target_states: Cow::Owned(vec![ColorTargetStateDescriptor {
219                format: device
220                    .get_surface_format()
221                    .unwrap_or(TextureFormat::Rgba8UnormSrgb),
222                blend: Some(khora_core::renderer::api::pipeline::BlendStateDescriptor {
223                    color: khora_core::renderer::api::pipeline::BlendComponentDescriptor {
224                        src_factor: khora_core::renderer::api::pipeline::BlendFactor::SrcAlpha,
225                        dst_factor:
226                            khora_core::renderer::api::pipeline::BlendFactor::OneMinusSrcAlpha,
227                        operation: khora_core::renderer::api::pipeline::BlendOperation::Add,
228                    },
229                    alpha: khora_core::renderer::api::pipeline::BlendComponentDescriptor {
230                        src_factor: khora_core::renderer::api::pipeline::BlendFactor::One,
231                        dst_factor: khora_core::renderer::api::pipeline::BlendFactor::Zero,
232                        operation: khora_core::renderer::api::pipeline::BlendOperation::Add,
233                    },
234                }),
235                write_mask: ColorWrites::ALL,
236            }]),
237            multisample_state: MultisampleStateDescriptor {
238                count: SampleCount::X1,
239                mask: !0,
240                alpha_to_coverage_enabled: false,
241            },
242        })?;
243
244        // 3. Texture & Sampler
245        let atlas = TextureAtlas::new(device, atlas_size, TextureFormat::R8Unorm, "text_atlas")?;
246
247        let sampler = device.create_sampler(&SamplerDescriptor {
248            label: Some(Cow::Borrowed("text_sampler")),
249            address_mode_u: AddressMode::ClampToEdge,
250            address_mode_v: AddressMode::ClampToEdge,
251            address_mode_w: AddressMode::ClampToEdge,
252            mag_filter: FilterMode::Linear,
253            min_filter: FilterMode::Linear,
254            mipmap_filter: MipmapFilterMode::Nearest,
255            lod_min_clamp: 0.0,
256            lod_max_clamp: 32.0,
257            compare: None,
258            anisotropy_clamp: 1,
259            border_color: None,
260        })?;
261
262        // Global uniform for projection (managed elsewhere or here?)
263        // For simplicity, we create a small uniform for the view_proj in the SDK if needed,
264        // but here we just need a buffer.
265        let uniform_buffer = device.create_buffer(&BufferDescriptor {
266            label: Some(Cow::Borrowed("text_uniforms")),
267            size: 64, // Mat4
268            usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
269            mapped_at_creation: false,
270        })?;
271
272        let bind_group = device.create_bind_group(&BindGroupDescriptor {
273            label: Some("text_bg"),
274            layout: bgl,
275            entries: &[
276                BindGroupEntry {
277                    binding: 0,
278                    resource: BindingResource::Buffer(BufferBinding {
279                        buffer: uniform_buffer,
280                        offset: 0,
281                        size: None,
282                    }),
283                    _phantom: std::marker::PhantomData,
284                },
285                BindGroupEntry {
286                    binding: 1,
287                    resource: BindingResource::TextureView(atlas.view()),
288                    _phantom: std::marker::PhantomData,
289                },
290                BindGroupEntry {
291                    binding: 2,
292                    resource: BindingResource::Sampler(sampler),
293                    _phantom: std::marker::PhantomData,
294                },
295            ],
296        })?;
297
298        let vertex_buffer = device.create_buffer(&BufferDescriptor {
299            label: Some(Cow::Borrowed("text_vertices")),
300            size: 1024 * 64, // 64KB for vertices
301            usage: BufferUsage::VERTEX | BufferUsage::COPY_DST,
302            mapped_at_creation: false,
303        })?;
304
305        let index_buffer = device.create_buffer(&BufferDescriptor {
306            label: Some(Cow::Borrowed("text_indices")),
307            size: 1024 * 32, // 32KB for indices
308            usage: BufferUsage::INDEX | BufferUsage::COPY_DST,
309            mapped_at_creation: false,
310        })?;
311
312        Ok(TextGpuResources {
313            pipeline,
314            bind_group_layout: bgl,
315            sampler,
316            atlas,
317            bind_group,
318            vertex_buffer,
319            index_buffer,
320        })
321    }
322}
323
324impl TextRenderer for StandardTextRenderer {
325    fn layout_text(
326        &self,
327        text: &str,
328        font: &Handle<Font>,
329        font_id: AssetUUID,
330        font_size: f32,
331        _max_width: Option<f32>,
332    ) -> Box<dyn TextLayout> {
333        let mut glyph_positions = Vec::new();
334        let mut cursor_x = 0.0;
335
336        let char_width = font_size * 0.6;
337        let char_height = font_size;
338
339        for c in text.chars() {
340            glyph_positions.push((c as u32, Vec2::new(cursor_x, 0.0)));
341            cursor_x += char_width;
342        }
343
344        Box::new(StandardTextLayout {
345            size: Vec2::new(cursor_x, char_height),
346            glyph_positions,
347            font_handle: font.clone(),
348            font_uuid: font_id,
349            font_size,
350        })
351    }
352
353    fn queue_text(&self, layout: &dyn TextLayout, pos: Vec2, color: Vec4, _z_index: i32) {
354        if let Some(std_layout) = layout.as_any().downcast_ref::<StandardTextLayout>() {
355            let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
356            queue.push(QueuedText {
357                layout: Arc::new(StandardTextLayout {
358                    size: std_layout.size,
359                    glyph_positions: std_layout.glyph_positions.clone(),
360                    font_handle: std_layout.font_handle.clone(),
361                    font_uuid: std_layout.font_uuid,
362                    font_size: std_layout.font_size,
363                }),
364                pos,
365                color,
366            });
367        }
368    }
369
370    fn flush(
371        &self,
372        device: &dyn GraphicsDevice,
373        encoder: &mut dyn CommandEncoder,
374        color_target: &TextureViewId,
375    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
376        let mut resources_lock = self
377            .gpu_resources
378            .lock()
379            .map_err(|_| "TextRenderer::flush: gpu_resources lock poisoned")?;
380        if resources_lock.is_none() {
381            *resources_lock = Some(self.init_resources(device)?);
382        }
383        let res = resources_lock.as_mut().unwrap();
384
385        let mut queue = self
386            .queue
387            .lock()
388            .map_err(|_| "TextRenderer::flush: queue lock poisoned")?;
389        if queue.is_empty() {
390            return Ok(());
391        }
392
393        // 1. Prepare Vertex/Index Data
394        let mut vertices = Vec::new();
395        let mut indices = Vec::new();
396        let mut vert_offset = 0;
397
398        for item in queue.iter() {
399            let font_uuid = item.layout.font_uuid;
400            let font_size_fixed = (item.layout.font_size * 10.0) as u32;
401
402            for (c_u32, g_pos) in &item.layout.glyph_positions {
403                let c = char::from_u32(*c_u32).unwrap_or('?');
404                let cache_key = (font_uuid, c, font_size_fixed);
405
406                // Get or rasterize glyph
407                let mut cache = self
408                    .glyph_cache
409                    .lock()
410                    .map_err(|_| "TextRenderer::flush: glyph cache lock poisoned")?;
411                let glyph = if let Some(g) = cache.get(&cache_key) {
412                    *g
413                } else if let Some((w, h, pixels)) = self.rasterize_glyph(c) {
414                    if let Some(rect) = res.atlas.allocate_and_upload(device, w, h, &pixels, 1) {
415                        let g = GlyphData {
416                            uv_min: rect.min,
417                            uv_max: rect.max,
418                            size: Vec2::new(w as f32, h as f32),
419                        };
420                        cache.insert(cache_key, g);
421                        g
422                    } else {
423                        continue; // Atlas full
424                    }
425                } else {
426                    continue; // Raster fail
427                };
428
429                let p = item.pos + *g_pos;
430                let s = item.layout.font_size;
431                let uv_min = glyph.uv_min;
432                let uv_max = glyph.uv_max;
433
434                // Quad: (TL, TR, BR, BL)
435                vertices.push(TextVertex {
436                    pos: p.to_array(),
437                    uv: [uv_min.x, uv_min.y],
438                    color: item.color.to_array(),
439                });
440                vertices.push(TextVertex {
441                    pos: (p + Vec2::new(s * 0.6, 0.0)).to_array(),
442                    uv: [uv_max.x, uv_min.y],
443                    color: item.color.to_array(),
444                });
445                vertices.push(TextVertex {
446                    pos: (p + Vec2::new(s * 0.6, s)).to_array(),
447                    uv: [uv_max.x, uv_max.y],
448                    color: item.color.to_array(),
449                });
450                vertices.push(TextVertex {
451                    pos: (p + Vec2::new(0.0, s)).to_array(),
452                    uv: [uv_min.x, uv_max.y],
453                    color: item.color.to_array(),
454                });
455
456                indices.push(vert_offset);
457                indices.push(vert_offset + 1);
458                indices.push(vert_offset + 2);
459                indices.push(vert_offset);
460                indices.push(vert_offset + 2);
461                indices.push(vert_offset + 3);
462
463                vert_offset += 4;
464            }
465        }
466
467        // 2. Upload Data
468        device.write_buffer(res.vertex_buffer, 0, bytemuck::cast_slice(&vertices))?;
469        device.write_buffer(res.index_buffer, 0, bytemuck::cast_slice(&indices))?;
470
471        // 3. Render Pass
472        let attachment = RenderPassColorAttachment {
473            view: color_target,
474            resolve_target: None,
475            ops: Operations {
476                load: LoadOp::<LinearRgba>::Load,
477                store: StoreOp::Store,
478            },
479            base_array_layer: 0,
480            base_mip_level: 0,
481        };
482
483        let attachments = [attachment];
484        let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
485            label: Some("text_flush_pass"),
486            color_attachments: &attachments,
487            depth_stencil_attachment: None,
488        });
489
490        pass.set_pipeline(&res.pipeline);
491        pass.set_bind_group(0, &res.bind_group, &[]);
492        pass.set_vertex_buffer(0, &res.vertex_buffer, 0);
493        pass.set_index_buffer(&res.index_buffer, 0, IndexFormat::Uint16);
494        pass.draw_indexed(0..(indices.len() as u32), 0, 0..1);
495
496        drop(pass);
497
498        queue.clear();
499        Ok(())
500    }
501}