Skip to main content

wisp/render/
text_pipeline.rs

1//! Bitmap text pipeline — M0.15.
2//!
3//! One instance per glyph. All glyphs from all `Text` nodes that share a font
4//! atlas batch into a single draw call. (Different fonts → different atlases →
5//! one batch per font, in the order encountered during scene traversal.)
6
7use std::collections::{HashMap, HashSet};
8
9use bytemuck::{Pod, Zeroable};
10use glam::{Mat4, Vec3};
11use wgpu::util::DeviceExt;
12
13use crate::application::Application;
14use crate::blend::BlendMode;
15use crate::render::blend_pipeline::BlendPipelineMap;
16use crate::render::scene_walk::walk_visible_subtree;
17use crate::scene::text::{Font, Text};
18use crate::scene::{Node, NodeId, Stage};
19
20#[repr(C)]
21#[derive(Clone, Copy, Pod, Zeroable)]
22pub(crate) struct TextInstance {
23    pub model: [[f32; 4]; 4],
24    pub color: [f32; 4],
25    pub uv_rect: [f32; 4],
26}
27
28const ATTR_LAYOUT: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![
29    0 => Float32x4,
30    1 => Float32x4,
31    2 => Float32x4,
32    3 => Float32x4,
33    4 => Float32x4,
34    5 => Float32x4,
35];
36
37pub(crate) struct TextPipeline {
38    pipelines: BlendPipelineMap,
39    texture_layout: wgpu::BindGroupLayout,
40}
41
42impl TextPipeline {
43    pub(crate) fn new(app: &Application, output_format: wgpu::TextureFormat) -> Self {
44        let device = app.device();
45        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
46            label: Some("wisp::text"),
47            source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/text.wgsl").into()),
48        });
49
50        let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
51            label: Some("wisp::text atlas layout"),
52            entries: &[
53                wgpu::BindGroupLayoutEntry {
54                    binding: 0,
55                    visibility: wgpu::ShaderStages::FRAGMENT,
56                    ty: wgpu::BindingType::Texture {
57                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
58                        view_dimension: wgpu::TextureViewDimension::D2,
59                        multisampled: false,
60                    },
61                    count: None,
62                },
63                wgpu::BindGroupLayoutEntry {
64                    binding: 1,
65                    visibility: wgpu::ShaderStages::FRAGMENT,
66                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
67                    count: None,
68                },
69            ],
70        });
71
72        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
73            label: Some("wisp::text pipeline layout"),
74            bind_group_layouts: &[&texture_layout],
75            push_constant_ranges: &[],
76        });
77
78        let pipelines = BlendPipelineMap::new(|blend| {
79            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
80                label: Some("wisp::text pipeline"),
81                layout: Some(&pipeline_layout),
82                vertex: wgpu::VertexState {
83                    module: &shader,
84                    entry_point: Some("main_vs"),
85                    buffers: &[wgpu::VertexBufferLayout {
86                        array_stride: std::mem::size_of::<TextInstance>() as wgpu::BufferAddress,
87                        step_mode: wgpu::VertexStepMode::Instance,
88                        attributes: &ATTR_LAYOUT,
89                    }],
90                    compilation_options: wgpu::PipelineCompilationOptions::default(),
91                },
92                fragment: Some(wgpu::FragmentState {
93                    module: &shader,
94                    entry_point: Some("main_fs"),
95                    targets: &[Some(wgpu::ColorTargetState {
96                        format: output_format,
97                        blend: Some(blend),
98                        write_mask: wgpu::ColorWrites::ALL,
99                    })],
100                    compilation_options: wgpu::PipelineCompilationOptions::default(),
101                }),
102                primitive: wgpu::PrimitiveState::default(),
103                depth_stencil: None,
104                multisample: wgpu::MultisampleState::default(),
105                multiview: None,
106                cache: None,
107            })
108        });
109
110        Self {
111            pipelines,
112            texture_layout,
113        }
114    }
115
116    /// Walk `stage`, batch glyph instances per font atlas, draw.
117    ///
118    /// Returns `(draw_calls, glyph_count)`. Each font atlas costs one batch.
119    pub(crate) fn draw_stage(
120        &self,
121        app: &Application,
122        pass: &mut wgpu::RenderPass<'_>,
123        stage: &Stage,
124    ) -> (u32, u32) {
125        self.draw_subtree(app, pass, stage, stage.root(), &HashSet::new())
126    }
127
128    /// Subtree variant — see `SpritePipeline::draw_subtree`.
129    pub(crate) fn draw_subtree(
130        &self,
131        app: &Application,
132        pass: &mut wgpu::RenderPass<'_>,
133        stage: &Stage,
134        start: NodeId,
135        exclude: &HashSet<NodeId>,
136    ) -> (u32, u32) {
137        let batches = collect_batches(stage, start, exclude);
138        let mut draw_calls = 0u32;
139        let mut glyphs = 0u32;
140
141        for batch in &batches {
142            if batch.instances.is_empty() {
143                continue;
144            }
145            let count = u32::try_from(batch.instances.len()).expect("glyph count fits in u32");
146            glyphs = glyphs.saturating_add(count);
147            let buffer = app
148                .device()
149                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
150                    label: Some("wisp::text instances"),
151                    contents: bytemuck::cast_slice(&batch.instances),
152                    usage: wgpu::BufferUsages::VERTEX,
153                });
154            let bg = app.device().create_bind_group(&wgpu::BindGroupDescriptor {
155                label: Some("wisp::text atlas bg"),
156                layout: &self.texture_layout,
157                entries: &[
158                    wgpu::BindGroupEntry {
159                        binding: 0,
160                        resource: wgpu::BindingResource::TextureView(batch.atlas.view()),
161                    },
162                    wgpu::BindGroupEntry {
163                        binding: 1,
164                        resource: wgpu::BindingResource::Sampler(batch.atlas.sampler()),
165                    },
166                ],
167            });
168
169            pass.set_pipeline(self.pipelines.get(batch.blend_mode));
170            pass.set_bind_group(0, &bg, &[]);
171            pass.set_vertex_buffer(0, buffer.slice(..));
172            pass.draw(0..6, 0..count);
173            draw_calls += 1;
174        }
175
176        (draw_calls, glyphs)
177    }
178}
179
180struct Batch {
181    atlas: crate::texture::Texture,
182    blend_mode: BlendMode,
183    instances: Vec<TextInstance>,
184}
185
186fn collect_batches(stage: &Stage, start: NodeId, exclude: &HashSet<NodeId>) -> Vec<Batch> {
187    type Key = (usize, BlendMode);
188    let mut grouped: HashMap<Key, (crate::texture::Texture, BlendMode, Vec<TextInstance>)> =
189        HashMap::new();
190    let mut order: Vec<Key> = Vec::new();
191
192    walk_visible_subtree(stage, start, exclude, |_id, node, world| {
193        let container = node.container();
194        if let Node::Text(text) = node {
195            push_text_glyphs(
196                text,
197                container.blend_mode,
198                world,
199                container.alpha,
200                &mut grouped,
201                &mut order,
202            );
203        }
204    });
205
206    order
207        .into_iter()
208        .filter_map(|key| {
209            grouped
210                .remove(&key)
211                .map(|(atlas, blend_mode, instances)| Batch {
212                    atlas,
213                    blend_mode,
214                    instances,
215                })
216        })
217        .collect()
218}
219
220fn push_text_glyphs(
221    text: &Text,
222    blend_mode: BlendMode,
223    world: Mat4,
224    parent_alpha: f32,
225    grouped: &mut HashMap<
226        (usize, BlendMode),
227        (crate::texture::Texture, BlendMode, Vec<TextInstance>),
228    >,
229    order: &mut Vec<(usize, BlendMode)>,
230) {
231    let key = (text.font.atlas().id(), blend_mode);
232    let atlas = text.font.atlas().clone();
233    let entry = grouped.entry(key).or_insert_with(|| {
234        order.push(key);
235        (atlas, blend_mode, Vec::new())
236    });
237
238    let cell_pixels = f32_from_u32(text.font.cell_pixels());
239    let glyph_size = text.cell_size * cell_pixels;
240    let advance = glyph_size;
241    let line_height = glyph_size * 1.25; // leading
242
243    let mut cursor_x = 0.0f32;
244    let mut cursor_y = 0.0f32;
245
246    for c in text.content.chars() {
247        if c == '\n' {
248            cursor_x = 0.0;
249            cursor_y -= line_height;
250            continue;
251        }
252
253        let Some(glyph) = font_glyph(&text.font, c) else {
254            cursor_x += advance;
255            continue;
256        };
257
258        // Place a unit-quad ([-1,+1]^2) at the glyph's center, scaled to glyph size.
259        let center_x = cursor_x + glyph_size * 0.5;
260        let center_y = cursor_y - glyph_size * 0.5;
261        let model = world
262            * Mat4::from_translation(Vec3::new(center_x, center_y, 0.0))
263            * Mat4::from_scale(Vec3::new(glyph_size * 0.5, glyph_size * 0.5, 1.0));
264
265        entry.2.push(TextInstance {
266            model: model.to_cols_array_2d(),
267            color: [
268                text.color.r,
269                text.color.g,
270                text.color.b,
271                text.color.a * parent_alpha,
272            ],
273            uv_rect: [glyph.u_min, glyph.v_min, glyph.u_max, glyph.v_max],
274        });
275
276        cursor_x += advance;
277    }
278}
279
280fn font_glyph(font: &Font, c: char) -> Option<crate::scene::text::GlyphMetrics> {
281    font.glyph(c)
282}
283
284fn f32_from_u32(v: u32) -> f32 {
285    // Cell pixel counts are tiny (8 today). Lossless within u23.
286    #[allow(
287        clippy::cast_precision_loss,
288        reason = "atlas cell pixels fit easily in f32 mantissa (8 today, max 1024)"
289    )]
290    {
291        v as f32
292    }
293}