repose_render_wgpu/
lib.rs

1use std::collections::HashMap;
2use std::num::NonZeroU32;
3use std::sync::Arc;
4use std::{borrow::Cow, sync::Once};
5
6use ab_glyph::{Font, FontArc, Glyph, PxScale, ScaleFont, point};
7use cosmic_text;
8use fontdb::Database;
9use image::GenericImageView;
10use repose_core::{Brush, Color, GlyphRasterConfig, RenderBackend, Scene, SceneNode, Transform};
11use std::panic::{AssertUnwindSafe, catch_unwind};
12
13static ROT_WARN_ONCE: Once = Once::new();
14
15#[derive(Clone)]
16struct UploadRing {
17    buf: wgpu::Buffer,
18    cap: u64,
19    head: u64,
20}
21impl UploadRing {
22    fn new(device: &wgpu::Device, label: &str, cap: u64) -> Self {
23        let buf = device.create_buffer(&wgpu::BufferDescriptor {
24            label: Some(label),
25            size: cap,
26            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
27            mapped_at_creation: false,
28        });
29        Self { buf, cap, head: 0 }
30    }
31    fn reset(&mut self) {
32        self.head = 0;
33    }
34    fn grow_to_fit(&mut self, device: &wgpu::Device, needed: u64) {
35        if needed <= self.cap {
36            return;
37        }
38        let new_cap = needed.next_power_of_two();
39        self.buf = device.create_buffer(&wgpu::BufferDescriptor {
40            label: Some("upload ring (grown)"),
41            size: new_cap,
42            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
43            mapped_at_creation: false,
44        });
45        self.cap = new_cap;
46        self.head = 0;
47    }
48    fn alloc_write(&mut self, queue: &wgpu::Queue, bytes: &[u8]) -> (u64, u64) {
49        let len = bytes.len() as u64;
50        let align = 4u64; // vertex buffer slice offset alignment
51        let start = (self.head + (align - 1)) & !(align - 1);
52        let end = start + len;
53        if end > self.cap {
54            // wrap and overwrite from start
55            self.head = 0;
56            let start = 0;
57            let end = len.min(self.cap);
58            queue.write_buffer(&self.buf, start, &bytes[0..end as usize]);
59            self.head = end;
60            (start, len.min(self.cap - start))
61        } else {
62            queue.write_buffer(&self.buf, start, bytes);
63            self.head = end;
64            (start, len)
65        }
66    }
67}
68
69pub struct WgpuBackend {
70    surface: wgpu::Surface<'static>,
71    device: wgpu::Device,
72    queue: wgpu::Queue,
73    config: wgpu::SurfaceConfiguration,
74
75    rect_pipeline: wgpu::RenderPipeline,
76    border_pipeline: wgpu::RenderPipeline,
77    ellipse_pipeline: wgpu::RenderPipeline,
78    ellipse_border_pipeline: wgpu::RenderPipeline,
79    text_pipeline_mask: wgpu::RenderPipeline,
80    text_pipeline_color: wgpu::RenderPipeline,
81    text_bind_layout: wgpu::BindGroupLayout,
82
83    // Glyph atlas
84    atlas_mask: AtlasA8,
85    atlas_color: AtlasRGBA,
86
87    // per-frame upload rings
88    ring_rect: UploadRing,
89    ring_border: UploadRing,
90    ring_ellipse: UploadRing,
91    ring_ellipse_border: UploadRing,
92    ring_glyph_mask: UploadRing,
93    ring_glyph_color: UploadRing,
94
95    next_image_handle: u64,
96    images: std::collections::HashMap<u64, ImageTex>,
97}
98
99struct ImageTex {
100    view: wgpu::TextureView,
101    bind: wgpu::BindGroup,
102    w: u32,
103    h: u32,
104}
105
106struct AtlasA8 {
107    tex: wgpu::Texture,
108    view: wgpu::TextureView,
109    sampler: wgpu::Sampler,
110    size: u32,
111    next_x: u32,
112    next_y: u32,
113    row_h: u32,
114    map: HashMap<(repose_text::GlyphKey, u32), GlyphInfo>,
115}
116
117struct AtlasRGBA {
118    tex: wgpu::Texture,
119    view: wgpu::TextureView,
120    sampler: wgpu::Sampler,
121    size: u32,
122    next_x: u32,
123    next_y: u32,
124    row_h: u32,
125    map: HashMap<(repose_text::GlyphKey, u32), GlyphInfo>,
126}
127
128#[derive(Clone, Copy)]
129struct GlyphInfo {
130    u0: f32,
131    v0: f32,
132    u1: f32,
133    v1: f32,
134    w: f32,
135    h: f32,
136    bearing_x: f32,
137    bearing_y: f32,
138    advance: f32,
139}
140
141#[repr(C)]
142#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
143struct RectInstance {
144    // xy in NDC, wh in NDC extents
145    xywh: [f32; 4],
146    // radius in NDC units
147    radius: f32,
148    // brush_type: 0 = solid, 1 = linear
149    brush_type: u32,
150    // solid_color or gradient endpoints in linear RGBA
151    color0: [f32; 4],
152    color1: [f32; 4],
153    // gradient vector in local space (use rect size to scale)
154    grad_start: [f32; 2], // normalized (0..1) in rect local coords
155    grad_end: [f32; 2],
156}
157
158#[repr(C)]
159#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
160struct BorderInstance {
161    // outer rect in NDC
162    xywh: [f32; 4],
163    // corner radius in NDC (for rounded-rects)
164    radius: f32,
165    // stroke width in NDC (screen-space thickness)
166    stroke: f32,
167    // rgba (linear)
168    color: [f32; 4],
169}
170
171#[repr(C)]
172#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
173struct EllipseInstance {
174    // bounding rect in NDC
175    xywh: [f32; 4],
176    // rgba (linear)
177    color: [f32; 4],
178}
179
180#[repr(C)]
181#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
182struct EllipseBorderInstance {
183    // bounding rect in NDC
184    xywh: [f32; 4],
185    // stroke width in NDC
186    stroke: f32,
187    // rgba (linear)
188    color: [f32; 4],
189}
190
191#[repr(C)]
192#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
193struct GlyphInstance {
194    // xywh in NDC
195    xywh: [f32; 4],
196    // uv
197    uv: [f32; 4],
198    // color
199    color: [f32; 4],
200}
201
202impl WgpuBackend {
203    pub fn new(window: Arc<winit::window::Window>) -> anyhow::Result<Self> {
204        // Instance/Surface (latest API with backend options from env)
205        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::from_env_or_default());
206        let surface = instance.create_surface(window.clone())?;
207
208        // Adapter/Device
209        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
210            power_preference: wgpu::PowerPreference::HighPerformance,
211            compatible_surface: Some(&surface),
212            force_fallback_adapter: false,
213        }))
214        .map_err(|_e| anyhow::anyhow!("No adapter"))?;
215
216        let (device, queue) =
217            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
218                label: Some("repose-rs device"),
219                required_features: wgpu::Features::empty(),
220                required_limits: wgpu::Limits::default(),
221                experimental_features: wgpu::ExperimentalFeatures::disabled(),
222                memory_hints: wgpu::MemoryHints::default(),
223                trace: wgpu::Trace::Off,
224            }))?;
225
226        let size = window.inner_size();
227
228        let caps = surface.get_capabilities(&adapter);
229        let format = caps
230            .formats
231            .iter()
232            .copied()
233            .find(|f| f.is_srgb()) // pick sRGB if available
234            .unwrap_or(caps.formats[0]);
235        let present_mode = caps
236            .present_modes
237            .iter()
238            .copied()
239            .find(|m| *m == wgpu::PresentMode::Mailbox || *m == wgpu::PresentMode::Immediate)
240            .unwrap_or(wgpu::PresentMode::Fifo);
241        let alpha_mode = caps.alpha_modes[0];
242
243        let config = wgpu::SurfaceConfiguration {
244            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
245            format,
246            width: size.width.max(1),
247            height: size.height.max(1),
248            present_mode,
249            alpha_mode,
250            view_formats: vec![],
251            desired_maximum_frame_latency: 2,
252        };
253        surface.configure(&device, &config);
254
255        // Pipelines: Rects
256        let rect_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
257            label: Some("rect.wgsl"),
258            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/rect.wgsl"))),
259        });
260        let rect_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
261            label: Some("rect pipeline layout"),
262            bind_group_layouts: &[],
263            push_constant_ranges: &[],
264        });
265        let rect_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
266            label: Some("rect pipeline"),
267            layout: Some(&rect_pipeline_layout),
268            vertex: wgpu::VertexState {
269                module: &rect_shader,
270                entry_point: Some("vs_main"),
271                buffers: &[wgpu::VertexBufferLayout {
272                    array_stride: std::mem::size_of::<RectInstance>() as u64,
273                    step_mode: wgpu::VertexStepMode::Instance,
274                    attributes: &[
275                        // xywh: vec4<f32>
276                        wgpu::VertexAttribute {
277                            shader_location: 0,
278                            offset: 0,
279                            format: wgpu::VertexFormat::Float32x4,
280                        },
281                        // radius: f32
282                        wgpu::VertexAttribute {
283                            shader_location: 1,
284                            offset: 16,
285                            format: wgpu::VertexFormat::Float32,
286                        },
287                        // brush_type: u32
288                        wgpu::VertexAttribute {
289                            shader_location: 2,
290                            offset: 20,
291                            format: wgpu::VertexFormat::Uint32,
292                        },
293                        // color0: vec4<f32>
294                        wgpu::VertexAttribute {
295                            shader_location: 3,
296                            offset: 24,
297                            format: wgpu::VertexFormat::Float32x4,
298                        },
299                        // color1: vec4<f32>
300                        wgpu::VertexAttribute {
301                            shader_location: 4,
302                            offset: 40,
303                            format: wgpu::VertexFormat::Float32x4,
304                        },
305                        // grad_start: vec2<f32>
306                        wgpu::VertexAttribute {
307                            shader_location: 5,
308                            offset: 56,
309                            format: wgpu::VertexFormat::Float32x2,
310                        },
311                        // grad_end: vec2<f32>
312                        wgpu::VertexAttribute {
313                            shader_location: 6,
314                            offset: 64,
315                            format: wgpu::VertexFormat::Float32x2,
316                        },
317                    ],
318                }],
319                compilation_options: wgpu::PipelineCompilationOptions::default(),
320            },
321            fragment: Some(wgpu::FragmentState {
322                module: &rect_shader,
323                entry_point: Some("fs_main"),
324                targets: &[Some(wgpu::ColorTargetState {
325                    format: config.format,
326                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
327                    write_mask: wgpu::ColorWrites::ALL,
328                })],
329                compilation_options: wgpu::PipelineCompilationOptions::default(),
330            }),
331            primitive: wgpu::PrimitiveState::default(),
332            depth_stencil: None,
333            multisample: wgpu::MultisampleState::default(),
334            multiview: None,
335            cache: None,
336        });
337
338        // Pipelines: Borders (SDF ring)
339        let border_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
340            label: Some("border.wgsl"),
341            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/border.wgsl"))),
342        });
343        let border_bind_layout =
344            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
345                label: Some("border bind layout"),
346                entries: &[],
347            });
348        let border_pipeline_layout =
349            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
350                label: Some("border pipeline layout"),
351                bind_group_layouts: &[], //&[&border_bind_layout],
352                push_constant_ranges: &[],
353            });
354        let border_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
355            label: Some("border pipeline"),
356            layout: Some(&border_pipeline_layout),
357            vertex: wgpu::VertexState {
358                module: &border_shader,
359                entry_point: Some("vs_main"),
360                buffers: &[wgpu::VertexBufferLayout {
361                    array_stride: std::mem::size_of::<BorderInstance>() as u64,
362                    step_mode: wgpu::VertexStepMode::Instance,
363                    attributes: &[
364                        // xywh
365                        wgpu::VertexAttribute {
366                            shader_location: 0,
367                            offset: 0,
368                            format: wgpu::VertexFormat::Float32x4,
369                        },
370                        // radius_outer
371                        wgpu::VertexAttribute {
372                            shader_location: 1,
373                            offset: 16,
374                            format: wgpu::VertexFormat::Float32,
375                        },
376                        // stroke
377                        wgpu::VertexAttribute {
378                            shader_location: 2,
379                            offset: 20,
380                            format: wgpu::VertexFormat::Float32,
381                        },
382                        // color
383                        wgpu::VertexAttribute {
384                            shader_location: 3,
385                            offset: 24,
386                            format: wgpu::VertexFormat::Float32x4,
387                        },
388                    ],
389                }],
390                compilation_options: wgpu::PipelineCompilationOptions::default(),
391            },
392            fragment: Some(wgpu::FragmentState {
393                module: &border_shader,
394                entry_point: Some("fs_main"),
395                targets: &[Some(wgpu::ColorTargetState {
396                    format: config.format,
397                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
398                    write_mask: wgpu::ColorWrites::ALL,
399                })],
400                compilation_options: wgpu::PipelineCompilationOptions::default(),
401            }),
402            primitive: wgpu::PrimitiveState::default(),
403            depth_stencil: None,
404            multisample: wgpu::MultisampleState::default(),
405            multiview: None,
406            cache: None,
407        });
408
409        let ellipse_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
410            label: Some("ellipse.wgsl"),
411            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/ellipse.wgsl"))),
412        });
413        let ellipse_pipeline_layout =
414            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
415                label: Some("ellipse pipeline layout"),
416                bind_group_layouts: &[],
417                push_constant_ranges: &[],
418            });
419        let ellipse_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
420            label: Some("ellipse pipeline"),
421            layout: Some(&ellipse_pipeline_layout),
422            vertex: wgpu::VertexState {
423                module: &ellipse_shader,
424                entry_point: Some("vs_main"),
425                buffers: &[wgpu::VertexBufferLayout {
426                    array_stride: std::mem::size_of::<EllipseInstance>() as u64,
427                    step_mode: wgpu::VertexStepMode::Instance,
428                    attributes: &[
429                        wgpu::VertexAttribute {
430                            shader_location: 0,
431                            offset: 0,
432                            format: wgpu::VertexFormat::Float32x4,
433                        },
434                        wgpu::VertexAttribute {
435                            shader_location: 1,
436                            offset: 16,
437                            format: wgpu::VertexFormat::Float32x4,
438                        },
439                    ],
440                }],
441                compilation_options: wgpu::PipelineCompilationOptions::default(),
442            },
443            fragment: Some(wgpu::FragmentState {
444                module: &ellipse_shader,
445                entry_point: Some("fs_main"),
446                targets: &[Some(wgpu::ColorTargetState {
447                    format: config.format,
448                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
449                    write_mask: wgpu::ColorWrites::ALL,
450                })],
451                compilation_options: wgpu::PipelineCompilationOptions::default(),
452            }),
453            primitive: wgpu::PrimitiveState::default(),
454            depth_stencil: None,
455            multisample: wgpu::MultisampleState::default(),
456            multiview: None,
457            cache: None,
458        });
459
460        // Pipelines: Ellipse border (ring)
461        let ellipse_border_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
462            label: Some("ellipse_border.wgsl"),
463            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
464                "shaders/ellipse_border.wgsl"
465            ))),
466        });
467        let ellipse_border_layout =
468            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
469                label: Some("ellipse border layout"),
470                bind_group_layouts: &[],
471                push_constant_ranges: &[],
472            });
473        let ellipse_border_pipeline =
474            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
475                label: Some("ellipse border pipeline"),
476                layout: Some(&ellipse_border_layout),
477                vertex: wgpu::VertexState {
478                    module: &ellipse_border_shader,
479                    entry_point: Some("vs_main"),
480                    buffers: &[wgpu::VertexBufferLayout {
481                        array_stride: std::mem::size_of::<EllipseBorderInstance>() as u64,
482                        step_mode: wgpu::VertexStepMode::Instance,
483                        attributes: &[
484                            wgpu::VertexAttribute {
485                                shader_location: 0,
486                                offset: 0,
487                                format: wgpu::VertexFormat::Float32x4,
488                            },
489                            wgpu::VertexAttribute {
490                                shader_location: 1,
491                                offset: 16,
492                                format: wgpu::VertexFormat::Float32,
493                            },
494                            wgpu::VertexAttribute {
495                                shader_location: 2,
496                                offset: 20,
497                                format: wgpu::VertexFormat::Float32x4,
498                            },
499                        ],
500                    }],
501                    compilation_options: wgpu::PipelineCompilationOptions::default(),
502                },
503                fragment: Some(wgpu::FragmentState {
504                    module: &ellipse_border_shader,
505                    entry_point: Some("fs_main"),
506                    targets: &[Some(wgpu::ColorTargetState {
507                        format: config.format,
508                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
509                        write_mask: wgpu::ColorWrites::ALL,
510                    })],
511                    compilation_options: wgpu::PipelineCompilationOptions::default(),
512                }),
513                primitive: wgpu::PrimitiveState::default(),
514                depth_stencil: None,
515                multisample: wgpu::MultisampleState::default(),
516                multiview: None,
517                cache: None,
518            });
519
520        // Pipelines: Text
521        let text_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
522            label: Some("text.wgsl"),
523            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/text.wgsl"))),
524        });
525        let text_color_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
526            label: Some("text_color.wgsl"),
527            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
528                "shaders/text_color.wgsl"
529            ))),
530        });
531        let text_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
532            label: Some("text bind layout"),
533            entries: &[
534                wgpu::BindGroupLayoutEntry {
535                    binding: 0,
536                    visibility: wgpu::ShaderStages::FRAGMENT,
537                    ty: wgpu::BindingType::Texture {
538                        multisampled: false,
539                        view_dimension: wgpu::TextureViewDimension::D2,
540                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
541                    },
542                    count: None,
543                },
544                wgpu::BindGroupLayoutEntry {
545                    binding: 1,
546                    visibility: wgpu::ShaderStages::FRAGMENT,
547                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
548                    count: None,
549                },
550            ],
551        });
552        let text_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
553            label: Some("text pipeline layout"),
554            bind_group_layouts: &[&text_bind_layout],
555            push_constant_ranges: &[],
556        });
557        let text_pipeline_mask = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
558            label: Some("text pipeline (mask)"),
559            layout: Some(&text_pipeline_layout),
560            vertex: wgpu::VertexState {
561                module: &text_mask_shader,
562                entry_point: Some("vs_main"),
563                buffers: &[wgpu::VertexBufferLayout {
564                    array_stride: std::mem::size_of::<GlyphInstance>() as u64,
565                    step_mode: wgpu::VertexStepMode::Instance,
566                    attributes: &[
567                        wgpu::VertexAttribute {
568                            shader_location: 0,
569                            offset: 0,
570                            format: wgpu::VertexFormat::Float32x4,
571                        },
572                        wgpu::VertexAttribute {
573                            shader_location: 1,
574                            offset: 16,
575                            format: wgpu::VertexFormat::Float32x4,
576                        },
577                        wgpu::VertexAttribute {
578                            shader_location: 2,
579                            offset: 32,
580                            format: wgpu::VertexFormat::Float32x4,
581                        },
582                    ],
583                }],
584                compilation_options: wgpu::PipelineCompilationOptions::default(),
585            },
586            fragment: Some(wgpu::FragmentState {
587                module: &text_mask_shader,
588                entry_point: Some("fs_main"),
589                targets: &[Some(wgpu::ColorTargetState {
590                    format: config.format,
591                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
592                    write_mask: wgpu::ColorWrites::ALL,
593                })],
594                compilation_options: wgpu::PipelineCompilationOptions::default(),
595            }),
596            primitive: wgpu::PrimitiveState::default(),
597            depth_stencil: None,
598            multisample: wgpu::MultisampleState::default(),
599            multiview: None,
600            cache: None,
601        });
602        let text_pipeline_color = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
603            label: Some("text pipeline (color)"),
604            layout: Some(&text_pipeline_layout),
605            vertex: wgpu::VertexState {
606                module: &text_color_shader,
607                entry_point: Some("vs_main"),
608                buffers: &[wgpu::VertexBufferLayout {
609                    array_stride: std::mem::size_of::<GlyphInstance>() as u64,
610                    step_mode: wgpu::VertexStepMode::Instance,
611                    attributes: &[
612                        wgpu::VertexAttribute {
613                            shader_location: 0,
614                            offset: 0,
615                            format: wgpu::VertexFormat::Float32x4,
616                        },
617                        wgpu::VertexAttribute {
618                            shader_location: 1,
619                            offset: 16,
620                            format: wgpu::VertexFormat::Float32x4,
621                        },
622                        wgpu::VertexAttribute {
623                            shader_location: 2,
624                            offset: 32,
625                            format: wgpu::VertexFormat::Float32x4,
626                        },
627                    ],
628                }],
629                compilation_options: wgpu::PipelineCompilationOptions::default(),
630            },
631            fragment: Some(wgpu::FragmentState {
632                module: &text_color_shader,
633                entry_point: Some("fs_main"),
634                targets: &[Some(wgpu::ColorTargetState {
635                    format: config.format,
636                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
637                    write_mask: wgpu::ColorWrites::ALL,
638                })],
639                compilation_options: wgpu::PipelineCompilationOptions::default(),
640            }),
641            primitive: wgpu::PrimitiveState::default(),
642            depth_stencil: None,
643            multisample: wgpu::MultisampleState::default(),
644            multiview: None,
645            cache: None,
646        });
647
648        // Atlases
649        let atlas_mask = Self::init_atlas_mask(&device)?;
650        let atlas_color = Self::init_atlas_color(&device)?;
651
652        // Upload rings (starts off small, grows in-place by recreating if needed — future work)
653        let ring_rect = UploadRing::new(&device, "ring rect", 1 << 20); // 1 MiB
654        let ring_border = UploadRing::new(&device, "ring border", 1 << 20);
655        let ring_ellipse = UploadRing::new(&device, "ring ellipse", 1 << 20);
656        let ring_ellipse_border = UploadRing::new(&device, "ring ellipse border", 1 << 20);
657        let ring_glyph_mask = UploadRing::new(&device, "ring glyph mask", 1 << 20);
658        let ring_glyph_color = UploadRing::new(&device, "ring glyph color", 1 << 20);
659
660        Ok(Self {
661            surface,
662            device,
663            queue,
664            config,
665            rect_pipeline,
666            border_pipeline,
667            text_pipeline_mask,
668            text_pipeline_color,
669            text_bind_layout,
670            ellipse_pipeline,
671            ellipse_border_pipeline,
672            atlas_mask,
673            atlas_color,
674            ring_rect,
675            ring_border,
676            ring_ellipse,
677            ring_ellipse_border,
678            ring_glyph_color,
679            ring_glyph_mask,
680            next_image_handle: 1,
681            images: HashMap::new(),
682        })
683    }
684
685    pub fn register_image_from_bytes(&mut self, data: &[u8], srgb: bool) -> u64 {
686        // Decode via image crate
687        let img = image::load_from_memory(data).expect("decode image");
688        let rgba = img.to_rgba8();
689        let (w, h) = rgba.dimensions();
690        // Texture format
691        let format = if srgb {
692            wgpu::TextureFormat::Rgba8UnormSrgb
693        } else {
694            wgpu::TextureFormat::Rgba8Unorm
695        };
696        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
697            label: Some("user image"),
698            size: wgpu::Extent3d {
699                width: w,
700                height: h,
701                depth_or_array_layers: 1,
702            },
703            mip_level_count: 1,
704            sample_count: 1,
705            dimension: wgpu::TextureDimension::D2,
706            format,
707            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
708            view_formats: &[],
709        });
710        self.queue.write_texture(
711            wgpu::TexelCopyTextureInfoBase {
712                texture: &tex,
713                mip_level: 0,
714                origin: wgpu::Origin3d::ZERO,
715                aspect: wgpu::TextureAspect::All,
716            },
717            &rgba,
718            wgpu::TexelCopyBufferLayout {
719                offset: 0,
720                bytes_per_row: Some(4 * w),
721                rows_per_image: Some(h),
722            },
723            wgpu::Extent3d {
724                width: w,
725                height: h,
726                depth_or_array_layers: 1,
727            },
728        );
729        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
730        let bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
731            label: Some("image bind"),
732            layout: &self.text_bind_layout, // same as text color pipeline
733            entries: &[
734                wgpu::BindGroupEntry {
735                    binding: 0,
736                    resource: wgpu::BindingResource::TextureView(&view),
737                },
738                wgpu::BindGroupEntry {
739                    binding: 1,
740                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
741                },
742            ],
743        });
744        let handle = self.next_image_handle;
745        self.next_image_handle += 1;
746        self.images.insert(handle, ImageTex { view, bind, w, h });
747        handle
748    }
749
750    fn init_atlas_mask(device: &wgpu::Device) -> anyhow::Result<AtlasA8> {
751        let size = 1024u32;
752        let tex = device.create_texture(&wgpu::TextureDescriptor {
753            label: Some("glyph atlas A8"),
754            size: wgpu::Extent3d {
755                width: size,
756                height: size,
757                depth_or_array_layers: 1,
758            },
759            mip_level_count: 1,
760            sample_count: 1,
761            dimension: wgpu::TextureDimension::D2,
762            format: wgpu::TextureFormat::R8Unorm,
763            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
764            view_formats: &[],
765        });
766        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
767        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
768            label: Some("glyph atlas sampler A8"),
769            address_mode_u: wgpu::AddressMode::ClampToEdge,
770            address_mode_v: wgpu::AddressMode::ClampToEdge,
771            address_mode_w: wgpu::AddressMode::ClampToEdge,
772            mag_filter: wgpu::FilterMode::Linear,
773            min_filter: wgpu::FilterMode::Linear,
774            mipmap_filter: wgpu::FilterMode::Linear,
775            ..Default::default()
776        });
777
778        Ok(AtlasA8 {
779            tex,
780            view,
781            sampler,
782            size,
783            next_x: 1,
784            next_y: 1,
785            row_h: 0,
786            map: HashMap::new(),
787        })
788    }
789
790    fn init_atlas_color(device: &wgpu::Device) -> anyhow::Result<AtlasRGBA> {
791        let size = 1024u32;
792        let tex = device.create_texture(&wgpu::TextureDescriptor {
793            label: Some("glyph atlas RGBA"),
794            size: wgpu::Extent3d {
795                width: size,
796                height: size,
797                depth_or_array_layers: 1,
798            },
799            mip_level_count: 1,
800            sample_count: 1,
801            dimension: wgpu::TextureDimension::D2,
802            format: wgpu::TextureFormat::Rgba8UnormSrgb,
803            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
804            view_formats: &[],
805        });
806        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
807        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
808            label: Some("glyph atlas sampler RGBA"),
809            address_mode_u: wgpu::AddressMode::ClampToEdge,
810            address_mode_v: wgpu::AddressMode::ClampToEdge,
811            address_mode_w: wgpu::AddressMode::ClampToEdge,
812            mag_filter: wgpu::FilterMode::Linear,
813            min_filter: wgpu::FilterMode::Linear,
814            mipmap_filter: wgpu::FilterMode::Linear,
815            ..Default::default()
816        });
817        Ok(AtlasRGBA {
818            tex,
819            view,
820            sampler,
821            size,
822            next_x: 1,
823            next_y: 1,
824            row_h: 0,
825            map: HashMap::new(),
826        })
827    }
828
829    fn atlas_bind_group_mask(&self) -> wgpu::BindGroup {
830        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
831            label: Some("atlas bind"),
832            layout: &self.text_bind_layout,
833            entries: &[
834                wgpu::BindGroupEntry {
835                    binding: 0,
836                    resource: wgpu::BindingResource::TextureView(&self.atlas_mask.view),
837                },
838                wgpu::BindGroupEntry {
839                    binding: 1,
840                    resource: wgpu::BindingResource::Sampler(&self.atlas_mask.sampler),
841                },
842            ],
843        })
844    }
845    fn atlas_bind_group_color(&self) -> wgpu::BindGroup {
846        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
847            label: Some("atlas bind color"),
848            layout: &self.text_bind_layout,
849            entries: &[
850                wgpu::BindGroupEntry {
851                    binding: 0,
852                    resource: wgpu::BindingResource::TextureView(&self.atlas_color.view),
853                },
854                wgpu::BindGroupEntry {
855                    binding: 1,
856                    resource: wgpu::BindingResource::Sampler(&self.atlas_color.sampler),
857                },
858            ],
859        })
860    }
861
862    fn upload_glyph_mask(&mut self, key: repose_text::GlyphKey, px: u32) -> Option<GlyphInfo> {
863        let keyp = (key, px);
864        if let Some(info) = self.atlas_mask.map.get(&keyp) {
865            return Some(*info);
866        }
867
868        let gb = repose_text::rasterize(key, px as f32)?;
869        if gb.w == 0 || gb.h == 0 || gb.data.is_empty() {
870            return None; //Whitespace, but doesn't get inserted?
871        }
872        if !matches!(
873            gb.content,
874            cosmic_text::SwashContent::Mask | cosmic_text::SwashContent::SubpixelMask
875        ) {
876            return None; // handled by color path
877        }
878        let w = gb.w.max(1);
879        let h = gb.h.max(1);
880        // Packing with growth (similar to RGBA atlas)
881        if !self.alloc_space_mask(w, h) {
882            self.grow_mask_and_rebuild();
883        }
884        if !self.alloc_space_mask(w, h) {
885            return None;
886        }
887        let x = self.atlas_mask.next_x;
888        let y = self.atlas_mask.next_y;
889        self.atlas_mask.next_x += w + 1;
890        self.atlas_mask.row_h = self.atlas_mask.row_h.max(h + 1);
891
892        let buf = gb.data;
893
894        // Upload
895        let layout = wgpu::TexelCopyBufferLayout {
896            offset: 0,
897            bytes_per_row: Some(w),
898            rows_per_image: Some(h),
899        };
900        let size = wgpu::Extent3d {
901            width: w,
902            height: h,
903            depth_or_array_layers: 1,
904        };
905        self.queue.write_texture(
906            wgpu::TexelCopyTextureInfoBase {
907                texture: &self.atlas_mask.tex,
908                mip_level: 0,
909                origin: wgpu::Origin3d { x, y, z: 0 },
910                aspect: wgpu::TextureAspect::All,
911            },
912            &buf,
913            layout,
914            size,
915        );
916
917        let info = GlyphInfo {
918            u0: x as f32 / self.atlas_mask.size as f32,
919            v0: y as f32 / self.atlas_mask.size as f32,
920            u1: (x + w) as f32 / self.atlas_mask.size as f32,
921            v1: (y + h) as f32 / self.atlas_mask.size as f32,
922            w: w as f32,
923            h: h as f32,
924            bearing_x: 0.0, // not used from atlas_mask so take it via shaping
925            bearing_y: 0.0,
926            advance: 0.0,
927        };
928        self.atlas_mask.map.insert(keyp, info);
929        Some(info)
930    }
931    fn upload_glyph_color(&mut self, key: repose_text::GlyphKey, px: u32) -> Option<GlyphInfo> {
932        let keyp = (key, px);
933        if let Some(info) = self.atlas_color.map.get(&keyp) {
934            return Some(*info);
935        }
936        let gb = repose_text::rasterize(key, px as f32)?;
937        if !matches!(gb.content, cosmic_text::SwashContent::Color) {
938            return None;
939        }
940        let w = gb.w.max(1);
941        let h = gb.h.max(1);
942        if !self.alloc_space_color(w, h) {
943            self.grow_color_and_rebuild();
944        }
945        if !self.alloc_space_color(w, h) {
946            return None;
947        }
948        let x = self.atlas_color.next_x;
949        let y = self.atlas_color.next_y;
950        self.atlas_color.next_x += w + 1;
951        self.atlas_color.row_h = self.atlas_color.row_h.max(h + 1);
952
953        let layout = wgpu::TexelCopyBufferLayout {
954            offset: 0,
955            bytes_per_row: Some(w * 4),
956            rows_per_image: Some(h),
957        };
958        let size = wgpu::Extent3d {
959            width: w,
960            height: h,
961            depth_or_array_layers: 1,
962        };
963        self.queue.write_texture(
964            wgpu::TexelCopyTextureInfoBase {
965                texture: &self.atlas_color.tex,
966                mip_level: 0,
967                origin: wgpu::Origin3d { x, y, z: 0 },
968                aspect: wgpu::TextureAspect::All,
969            },
970            &gb.data,
971            layout,
972            size,
973        );
974        let info = GlyphInfo {
975            u0: x as f32 / self.atlas_color.size as f32,
976            v0: y as f32 / self.atlas_color.size as f32,
977            u1: (x + w) as f32 / self.atlas_color.size as f32,
978            v1: (y + h) as f32 / self.atlas_color.size as f32,
979            w: w as f32,
980            h: h as f32,
981            bearing_x: 0.0,
982            bearing_y: 0.0,
983            advance: 0.0,
984        };
985        self.atlas_color.map.insert(keyp, info);
986        Some(info)
987    }
988
989    // Atlas alloc/grow (A8)
990    fn alloc_space_mask(&mut self, w: u32, h: u32) -> bool {
991        if self.atlas_mask.next_x + w + 1 >= self.atlas_mask.size {
992            self.atlas_mask.next_x = 1;
993            self.atlas_mask.next_y += self.atlas_mask.row_h + 1;
994            self.atlas_mask.row_h = 0;
995        }
996        if self.atlas_mask.next_y + h + 1 >= self.atlas_mask.size {
997            return false;
998        }
999        true
1000    }
1001    fn grow_mask_and_rebuild(&mut self) {
1002        let new_size = (self.atlas_mask.size * 2).min(4096);
1003        if new_size == self.atlas_mask.size {
1004            return;
1005        }
1006        // recreate texture
1007        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
1008            label: Some("glyph atlas A8 (grown)"),
1009            size: wgpu::Extent3d {
1010                width: new_size,
1011                height: new_size,
1012                depth_or_array_layers: 1,
1013            },
1014            mip_level_count: 1,
1015            sample_count: 1,
1016            dimension: wgpu::TextureDimension::D2,
1017            format: wgpu::TextureFormat::R8Unorm,
1018            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1019            view_formats: &[],
1020        });
1021        self.atlas_mask.tex = tex;
1022        self.atlas_mask.view = self
1023            .atlas_mask
1024            .tex
1025            .create_view(&wgpu::TextureViewDescriptor::default());
1026        self.atlas_mask.size = new_size;
1027        self.atlas_mask.next_x = 1;
1028        self.atlas_mask.next_y = 1;
1029        self.atlas_mask.row_h = 0;
1030        // rebuild all keys
1031        let keys: Vec<(repose_text::GlyphKey, u32)> = self.atlas_mask.map.keys().copied().collect();
1032        self.atlas_mask.map.clear();
1033        for (k, px) in keys {
1034            let _ = self.upload_glyph_mask(k, px);
1035        }
1036    }
1037    // Atlas alloc/grow (RGBA)
1038    fn alloc_space_color(&mut self, w: u32, h: u32) -> bool {
1039        if self.atlas_color.next_x + w + 1 >= self.atlas_color.size {
1040            self.atlas_color.next_x = 1;
1041            self.atlas_color.next_y += self.atlas_color.row_h + 1;
1042            self.atlas_color.row_h = 0;
1043        }
1044        if self.atlas_color.next_y + h + 1 >= self.atlas_color.size {
1045            return false;
1046        }
1047        true
1048    }
1049    fn grow_color_and_rebuild(&mut self) {
1050        let new_size = (self.atlas_color.size * 2).min(4096);
1051        if new_size == self.atlas_color.size {
1052            return;
1053        }
1054        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
1055            label: Some("glyph atlas RGBA (grown)"),
1056            size: wgpu::Extent3d {
1057                width: new_size,
1058                height: new_size,
1059                depth_or_array_layers: 1,
1060            },
1061            mip_level_count: 1,
1062            sample_count: 1,
1063            dimension: wgpu::TextureDimension::D2,
1064            format: wgpu::TextureFormat::Rgba8UnormSrgb,
1065            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1066            view_formats: &[],
1067        });
1068        self.atlas_color.tex = tex;
1069        self.atlas_color.view = self
1070            .atlas_color
1071            .tex
1072            .create_view(&wgpu::TextureViewDescriptor::default());
1073        self.atlas_color.size = new_size;
1074        self.atlas_color.next_x = 1;
1075        self.atlas_color.next_y = 1;
1076        self.atlas_color.row_h = 0;
1077        let keys: Vec<(repose_text::GlyphKey, u32)> =
1078            self.atlas_color.map.keys().copied().collect();
1079        self.atlas_color.map.clear();
1080        for (k, px) in keys {
1081            let _ = self.upload_glyph_color(k, px);
1082        }
1083    }
1084}
1085
1086/// Helper to convert a Brush to RectInstance fields
1087fn brush_to_instance_fields(brush: &Brush) -> (u32, [f32; 4], [f32; 4], [f32; 2], [f32; 2]) {
1088    match brush {
1089        Brush::Solid(c) => (
1090            0u32,
1091            c.to_linear(),
1092            [0.0, 0.0, 0.0, 0.0],
1093            [0.0, 0.0],
1094            [0.0, 1.0],
1095        ),
1096        Brush::Linear {
1097            start,
1098            end,
1099            start_color,
1100            end_color,
1101        } => (
1102            1u32,
1103            start_color.to_linear(),
1104            end_color.to_linear(),
1105            [start.x, start.y],
1106            [end.x, end.y],
1107        ),
1108    }
1109}
1110
1111/// Helper to extract a solid color from a Brush (for primitives that don't support gradients yet)
1112fn brush_to_solid_color(brush: &Brush) -> [f32; 4] {
1113    match brush {
1114        Brush::Solid(c) => c.to_linear(),
1115        Brush::Linear { start_color, .. } => start_color.to_linear(),
1116    }
1117}
1118
1119impl RenderBackend for WgpuBackend {
1120    fn configure_surface(&mut self, width: u32, height: u32) {
1121        if width == 0 || height == 0 {
1122            return;
1123        }
1124        self.config.width = width;
1125        self.config.height = height;
1126        self.surface.configure(&self.device, &self.config);
1127    }
1128
1129    fn frame(&mut self, scene: &Scene, _glyph_cfg: GlyphRasterConfig) {
1130        if self.config.width == 0 || self.config.height == 0 {
1131            return;
1132        }
1133        let frame = loop {
1134            match self.surface.get_current_texture() {
1135                Ok(f) => break f,
1136                Err(wgpu::SurfaceError::Lost) => {
1137                    log::warn!("surface lost; reconfiguring");
1138                    self.surface.configure(&self.device, &self.config);
1139                }
1140                Err(wgpu::SurfaceError::Outdated) => {
1141                    log::warn!("surface outdated; reconfiguring");
1142                    self.surface.configure(&self.device, &self.config);
1143                }
1144                Err(wgpu::SurfaceError::Timeout) => {
1145                    log::warn!("surface timeout; retrying");
1146                    continue;
1147                }
1148                Err(wgpu::SurfaceError::OutOfMemory) => {
1149                    log::error!("surface OOM");
1150                    return;
1151                }
1152                Err(wgpu::SurfaceError::Other) => {
1153                    log::error!("Other error");
1154                    return;
1155                }
1156            }
1157        };
1158        let view = frame
1159            .texture
1160            .create_view(&wgpu::TextureViewDescriptor::default());
1161
1162        // Helper: pixels -> NDC
1163        fn to_ndc(x: f32, y: f32, w: f32, h: f32, fb_w: f32, fb_h: f32) -> [f32; 4] {
1164            let x0 = (x / fb_w) * 2.0 - 1.0;
1165            let y0 = 1.0 - (y / fb_h) * 2.0;
1166            let x1 = ((x + w) / fb_w) * 2.0 - 1.0;
1167            let y1 = 1.0 - ((y + h) / fb_h) * 2.0;
1168            let min_x = x0.min(x1);
1169            let min_y = y0.min(y1);
1170            let w_ndc = (x1 - x0).abs();
1171            let h_ndc = (y1 - y0).abs();
1172            [min_x, min_y, w_ndc, h_ndc]
1173        }
1174        fn to_ndc_scalar(px: f32, fb_dim: f32) -> f32 {
1175            (px / fb_dim) * 2.0
1176        }
1177        fn to_ndc_radius(r: f32, fb_w: f32, fb_h: f32) -> f32 {
1178            let rx = to_ndc_scalar(r, fb_w);
1179            let ry = to_ndc_scalar(r, fb_h);
1180            rx.min(ry)
1181        }
1182        fn to_ndc_stroke(w: f32, fb_w: f32, fb_h: f32) -> f32 {
1183            let sx = to_ndc_scalar(w, fb_w);
1184            let sy = to_ndc_scalar(w, fb_h);
1185            sx.min(sy)
1186        }
1187        fn to_scissor(r: &repose_core::Rect, fb_w: u32, fb_h: u32) -> (u32, u32, u32, u32) {
1188            // Clamp origin inside framebuffer
1189            let mut x = r.x.floor() as i64;
1190            let mut y = r.y.floor() as i64;
1191            let fb_wi = fb_w as i64;
1192            let fb_hi = fb_h as i64;
1193            x = x.clamp(0, fb_wi.saturating_sub(1));
1194            y = y.clamp(0, fb_hi.saturating_sub(1));
1195            // Compute width/height s.t. rect stays in-bounds and is at least 1x1
1196            let w_req = r.w.ceil().max(1.0) as i64;
1197            let h_req = r.h.ceil().max(1.0) as i64;
1198            let w = (w_req).min(fb_wi - x).max(1);
1199            let h = (h_req).min(fb_hi - y).max(1);
1200            (x as u32, y as u32, w as u32, h as u32)
1201        }
1202
1203        let fb_w = self.config.width as f32;
1204        let fb_h = self.config.height as f32;
1205
1206        // Prebuild draw commands, batching per pipeline between clip boundaries
1207        enum Cmd {
1208            SetClipPush(repose_core::Rect),
1209            SetClipPop,
1210            Rect { off: u64, cnt: u32 },
1211            Border { off: u64, cnt: u32 },
1212            Ellipse { off: u64, cnt: u32 },
1213            EllipseBorder { off: u64, cnt: u32 },
1214            GlyphsMask { off: u64, cnt: u32 },
1215            GlyphsColor { off: u64, cnt: u32 },
1216            Image { off: u64, cnt: u32, handle: u64 },
1217            PushTransform(Transform),
1218            PopTransform,
1219        }
1220        let mut cmds: Vec<Cmd> = Vec::with_capacity(scene.nodes.len());
1221        struct Batch {
1222            rects: Vec<RectInstance>,
1223            borders: Vec<BorderInstance>,
1224            ellipses: Vec<EllipseInstance>,
1225            e_borders: Vec<EllipseBorderInstance>,
1226            masks: Vec<GlyphInstance>,
1227            colors: Vec<GlyphInstance>,
1228        }
1229        impl Batch {
1230            fn new() -> Self {
1231                Self {
1232                    rects: vec![],
1233                    borders: vec![],
1234                    ellipses: vec![],
1235                    e_borders: vec![],
1236                    masks: vec![],
1237                    colors: vec![],
1238                }
1239            }
1240
1241            fn flush(
1242                &mut self,
1243                rings: (
1244                    &mut UploadRing,
1245                    &mut UploadRing,
1246                    &mut UploadRing,
1247                    &mut UploadRing,
1248                    &mut UploadRing,
1249                    &mut UploadRing,
1250                ),
1251                device: &wgpu::Device,
1252                queue: &wgpu::Queue,
1253                cmds: &mut Vec<Cmd>,
1254            ) {
1255                let (
1256                    ring_rect,
1257                    ring_border,
1258                    ring_ellipse,
1259                    ring_ellipse_border,
1260                    ring_mask,
1261                    ring_color,
1262                ) = rings;
1263
1264                if !self.rects.is_empty() {
1265                    let bytes = bytemuck::cast_slice(&self.rects);
1266                    ring_rect.grow_to_fit(device, bytes.len() as u64);
1267                    let (off, wrote) = ring_rect.alloc_write(queue, bytes);
1268                    debug_assert_eq!(wrote as usize, bytes.len());
1269                    cmds.push(Cmd::Rect {
1270                        off,
1271                        cnt: self.rects.len() as u32,
1272                    });
1273                    self.rects.clear();
1274                }
1275                if !self.borders.is_empty() {
1276                    let bytes = bytemuck::cast_slice(&self.borders);
1277                    ring_border.grow_to_fit(device, bytes.len() as u64);
1278                    let (off, wrote) = ring_border.alloc_write(queue, bytes);
1279                    debug_assert_eq!(wrote as usize, bytes.len());
1280                    cmds.push(Cmd::Border {
1281                        off,
1282                        cnt: self.borders.len() as u32,
1283                    });
1284                    self.borders.clear();
1285                }
1286                if !self.ellipses.is_empty() {
1287                    let bytes = bytemuck::cast_slice(&self.ellipses);
1288                    ring_ellipse.grow_to_fit(device, bytes.len() as u64);
1289                    let (off, wrote) = ring_ellipse.alloc_write(queue, bytes);
1290                    debug_assert_eq!(wrote as usize, bytes.len());
1291                    cmds.push(Cmd::Ellipse {
1292                        off,
1293                        cnt: self.ellipses.len() as u32,
1294                    });
1295                    self.ellipses.clear();
1296                }
1297                if !self.e_borders.is_empty() {
1298                    let bytes = bytemuck::cast_slice(&self.e_borders);
1299                    ring_ellipse_border.grow_to_fit(device, bytes.len() as u64);
1300                    let (off, wrote) = ring_ellipse_border.alloc_write(queue, bytes);
1301                    debug_assert_eq!(wrote as usize, bytes.len());
1302                    cmds.push(Cmd::EllipseBorder {
1303                        off,
1304                        cnt: self.e_borders.len() as u32,
1305                    });
1306                    self.e_borders.clear();
1307                }
1308                if !self.masks.is_empty() {
1309                    let bytes = bytemuck::cast_slice(&self.masks);
1310                    ring_mask.grow_to_fit(device, bytes.len() as u64);
1311                    let (off, wrote) = ring_mask.alloc_write(queue, bytes);
1312                    debug_assert_eq!(wrote as usize, bytes.len());
1313                    cmds.push(Cmd::GlyphsMask {
1314                        off,
1315                        cnt: self.masks.len() as u32,
1316                    });
1317                    self.masks.clear();
1318                }
1319                if !self.colors.is_empty() {
1320                    let bytes = bytemuck::cast_slice(&self.colors);
1321                    ring_color.grow_to_fit(device, bytes.len() as u64);
1322                    let (off, wrote) = ring_color.alloc_write(queue, bytes);
1323                    debug_assert_eq!(wrote as usize, bytes.len());
1324                    cmds.push(Cmd::GlyphsColor {
1325                        off,
1326                        cnt: self.colors.len() as u32,
1327                    });
1328                    self.colors.clear();
1329                }
1330            }
1331        }
1332        // per frame
1333        self.ring_rect.reset();
1334        self.ring_border.reset();
1335        self.ring_ellipse.reset();
1336        self.ring_ellipse_border.reset();
1337        self.ring_glyph_mask.reset();
1338        self.ring_glyph_color.reset();
1339        let mut batch = Batch::new();
1340
1341        let mut transform_stack: Vec<Transform> = vec![Transform::identity()];
1342
1343        for node in &scene.nodes {
1344            let t_identity = Transform::identity();
1345            let current_transform = transform_stack.last().unwrap_or(&t_identity);
1346
1347            match node {
1348                SceneNode::Rect {
1349                    rect,
1350                    brush,
1351                    radius,
1352                } => {
1353                    let transformed_rect = current_transform.apply_to_rect(*rect);
1354                    let (brush_type, color0, color1, grad_start, grad_end) =
1355                        brush_to_instance_fields(brush);
1356                    batch.rects.push(RectInstance {
1357                        xywh: to_ndc(
1358                            transformed_rect.x,
1359                            transformed_rect.y,
1360                            transformed_rect.w,
1361                            transformed_rect.h,
1362                            fb_w,
1363                            fb_h,
1364                        ),
1365                        radius: to_ndc_radius(*radius, fb_w, fb_h),
1366                        brush_type,
1367                        color0,
1368                        color1,
1369                        grad_start,
1370                        grad_end,
1371                    });
1372                }
1373                SceneNode::Border {
1374                    rect,
1375                    color,
1376                    width,
1377                    radius,
1378                } => {
1379                    let transformed_rect = current_transform.apply_to_rect(*rect);
1380
1381                    batch.borders.push(BorderInstance {
1382                        xywh: to_ndc(
1383                            transformed_rect.x,
1384                            transformed_rect.y,
1385                            transformed_rect.w,
1386                            transformed_rect.h,
1387                            fb_w,
1388                            fb_h,
1389                        ),
1390                        radius: to_ndc_radius(*radius, fb_w, fb_h),
1391                        stroke: to_ndc_stroke(*width, fb_w, fb_h),
1392                        color: color.to_linear(),
1393                    });
1394                }
1395                SceneNode::Ellipse { rect, brush } => {
1396                    let transformed = current_transform.apply_to_rect(*rect);
1397                    let color = brush_to_solid_color(brush);
1398                    batch.ellipses.push(EllipseInstance {
1399                        xywh: to_ndc(
1400                            transformed.x,
1401                            transformed.y,
1402                            transformed.w,
1403                            transformed.h,
1404                            fb_w,
1405                            fb_h,
1406                        ),
1407                        color,
1408                    });
1409                }
1410                SceneNode::EllipseBorder { rect, color, width } => {
1411                    let transformed = current_transform.apply_to_rect(*rect);
1412                    batch.e_borders.push(EllipseBorderInstance {
1413                        xywh: to_ndc(
1414                            transformed.x,
1415                            transformed.y,
1416                            transformed.w,
1417                            transformed.h,
1418                            fb_w,
1419                            fb_h,
1420                        ),
1421                        stroke: to_ndc_stroke(*width, fb_w, fb_h),
1422                        color: color.to_linear(),
1423                    });
1424                }
1425                SceneNode::Text {
1426                    rect,
1427                    text,
1428                    color,
1429                    size,
1430                } => {
1431                    let px = (*size).clamp(8.0, 96.0);
1432                    let shaped = repose_text::shape_line(text, px);
1433
1434                    let transformed_rect = current_transform.apply_to_rect(*rect);
1435
1436                    for sg in shaped {
1437                        // Try color first; if not color, try mask
1438                        if let Some(info) = self.upload_glyph_color(sg.key, px as u32) {
1439                            let x = transformed_rect.x + sg.x + sg.bearing_x;
1440                            let y = transformed_rect.y + sg.y - sg.bearing_y;
1441                            batch.colors.push(GlyphInstance {
1442                                xywh: to_ndc(x, y, info.w, info.h, fb_w, fb_h),
1443                                uv: [info.u0, info.v1, info.u1, info.v0],
1444                                color: [1.0, 1.0, 1.0, 1.0], // do not tint color glyphs
1445                            });
1446                        } else if let Some(info) = self.upload_glyph_mask(sg.key, px as u32) {
1447                            let x = transformed_rect.x + sg.x + sg.bearing_x;
1448                            let y = transformed_rect.y + sg.y - sg.bearing_y;
1449                            batch.masks.push(GlyphInstance {
1450                                xywh: to_ndc(x, y, info.w, info.h, fb_w, fb_h),
1451                                uv: [info.u0, info.v1, info.u1, info.v0],
1452                                color: color.to_linear(),
1453                            });
1454                        }
1455                    }
1456                }
1457                SceneNode::Image {
1458                    rect,
1459                    handle,
1460                    tint,
1461                    fit,
1462                } => {
1463                    let tex = if let Some(t) = self.images.get(handle) {
1464                        t
1465                    } else {
1466                        log::warn!("Image handle {} not found", handle);
1467                        continue;
1468                    };
1469                    let src_w = tex.w as f32;
1470                    let src_h = tex.h as f32;
1471                    let dst_w = rect.w.max(0.0);
1472                    let dst_h = rect.h.max(0.0);
1473                    if dst_w <= 0.0 || dst_h <= 0.0 {
1474                        continue;
1475                    }
1476                    // Compute fit
1477                    let (xywh_ndc, uv_rect) = match fit {
1478                        repose_core::view::ImageFit::Contain => {
1479                            let scale = (dst_w / src_w).min(dst_h / src_h);
1480                            let w = src_w * scale;
1481                            let h = src_h * scale;
1482                            let x = rect.x + (dst_w - w) * 0.5;
1483                            let y = rect.y + (dst_h - h) * 0.5;
1484                            (to_ndc(x, y, w, h, fb_w, fb_h), [0.0, 1.0, 1.0, 0.0])
1485                        }
1486                        repose_core::view::ImageFit::Cover => {
1487                            let scale = (dst_w / src_w).max(dst_h / src_h);
1488                            let content_w = src_w * scale;
1489                            let content_h = src_h * scale;
1490                            // Overflow in dst space
1491                            let overflow_x = (content_w - dst_w) * 0.5;
1492                            let overflow_y = (content_h - dst_h) * 0.5;
1493                            // UV clamp to center crop
1494                            let u0 = (overflow_x / content_w).clamp(0.0, 1.0);
1495                            let v0 = (overflow_y / content_h).clamp(0.0, 1.0);
1496                            let u1 = ((overflow_x + dst_w) / content_w).clamp(0.0, 1.0);
1497                            let v1 = ((overflow_y + dst_h) / content_h).clamp(0.0, 1.0);
1498                            (
1499                                to_ndc(rect.x, rect.y, dst_w, dst_h, fb_w, fb_h),
1500                                [u0, 1.0 - v1, u1, 1.0 - v0],
1501                            )
1502                        }
1503                        repose_core::view::ImageFit::FitWidth => {
1504                            let scale = dst_w / src_w;
1505                            let w = dst_w;
1506                            let h = src_h * scale;
1507                            let y = rect.y + (dst_h - h) * 0.5;
1508                            (to_ndc(rect.x, y, w, h, fb_w, fb_h), [0.0, 1.0, 1.0, 0.0])
1509                        }
1510                        repose_core::view::ImageFit::FitHeight => {
1511                            let scale = dst_h / src_h;
1512                            let w = src_w * scale;
1513                            let h = dst_h;
1514                            let x = rect.x + (dst_w - w) * 0.5;
1515                            (to_ndc(x, rect.y, w, h, fb_w, fb_h), [0.0, 1.0, 1.0, 0.0])
1516                        }
1517                    };
1518                    let inst = GlyphInstance {
1519                        xywh: xywh_ndc,
1520                        uv: uv_rect,
1521                        color: tint.to_linear(),
1522                    };
1523                    let bytes = bytemuck::bytes_of(&inst);
1524                    let (off, wrote) = self.ring_glyph_color.alloc_write(&self.queue, bytes);
1525                    debug_assert_eq!(wrote as usize, bytes.len());
1526                    // Flush current batches so we can bind per-image texture, then queue single draw
1527                    batch.flush(
1528                        (
1529                            &mut self.ring_rect,
1530                            &mut self.ring_border,
1531                            &mut self.ring_ellipse,
1532                            &mut self.ring_ellipse_border,
1533                            &mut self.ring_glyph_mask,
1534                            &mut self.ring_glyph_color,
1535                        ),
1536                        &self.device,
1537                        &self.queue,
1538                        &mut cmds,
1539                    );
1540                    cmds.push(Cmd::Image {
1541                        off,
1542                        cnt: 1,
1543                        handle: *handle,
1544                    });
1545                }
1546                SceneNode::PushClip { rect, .. } => {
1547                    batch.flush(
1548                        (
1549                            &mut self.ring_rect,
1550                            &mut self.ring_border,
1551                            &mut self.ring_ellipse,
1552                            &mut self.ring_ellipse_border,
1553                            &mut self.ring_glyph_mask,
1554                            &mut self.ring_glyph_color,
1555                        ),
1556                        &self.device,
1557                        &self.queue,
1558                        &mut cmds,
1559                    );
1560                    let t_identity = Transform::identity();
1561                    let current_transform = transform_stack.last().unwrap_or(&t_identity);
1562                    let transformed = current_transform.apply_to_rect(*rect);
1563                    cmds.push(Cmd::SetClipPush(transformed));
1564                }
1565                SceneNode::PopClip => {
1566                    batch.flush(
1567                        (
1568                            &mut self.ring_rect,
1569                            &mut self.ring_border,
1570                            &mut self.ring_ellipse,
1571                            &mut self.ring_ellipse_border,
1572                            &mut self.ring_glyph_mask,
1573                            &mut self.ring_glyph_color,
1574                        ),
1575                        &self.device,
1576                        &self.queue,
1577                        &mut cmds,
1578                    );
1579                    cmds.push(Cmd::SetClipPop);
1580                }
1581                SceneNode::PushTransform { transform } => {
1582                    let combined = current_transform.combine(transform);
1583                    if transform.rotate != 0.0 {
1584                        ROT_WARN_ONCE.call_once(|| {
1585                            log::warn!(
1586                                "Transform rotation is not supported for Rect/Text/Image; rotation will be ignored."
1587                            );
1588                        });
1589                    }
1590                    transform_stack.push(combined);
1591                }
1592                SceneNode::PopTransform => {
1593                    transform_stack.pop();
1594                }
1595            }
1596        }
1597
1598        batch.flush(
1599            (
1600                &mut self.ring_rect,
1601                &mut self.ring_border,
1602                &mut self.ring_ellipse,
1603                &mut self.ring_ellipse_border,
1604                &mut self.ring_glyph_mask,
1605                &mut self.ring_glyph_color,
1606            ),
1607            &self.device,
1608            &self.queue,
1609            &mut cmds,
1610        );
1611
1612        let mut encoder = self
1613            .device
1614            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1615                label: Some("frame encoder"),
1616            });
1617
1618        {
1619            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1620                label: Some("main pass"),
1621                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1622                    view: &view,
1623                    resolve_target: None,
1624                    ops: wgpu::Operations {
1625                        load: wgpu::LoadOp::Clear(wgpu::Color {
1626                            r: scene.clear_color.0 as f64 / 255.0,
1627                            g: scene.clear_color.1 as f64 / 255.0,
1628                            b: scene.clear_color.2 as f64 / 255.0,
1629                            a: scene.clear_color.3 as f64 / 255.0,
1630                        }),
1631                        store: wgpu::StoreOp::Store,
1632                    },
1633                    depth_slice: None,
1634                })],
1635                depth_stencil_attachment: None,
1636                timestamp_writes: None,
1637                occlusion_query_set: None,
1638            });
1639
1640            // initial full scissor
1641            rpass.set_scissor_rect(0, 0, self.config.width, self.config.height);
1642            let bind_mask = self.atlas_bind_group_mask();
1643            let bind_color = self.atlas_bind_group_color();
1644            let root_clip = repose_core::Rect {
1645                x: 0.0,
1646                y: 0.0,
1647                w: fb_w,
1648                h: fb_h,
1649            };
1650            let mut clip_stack: Vec<repose_core::Rect> = Vec::with_capacity(8);
1651
1652            for cmd in cmds {
1653                match cmd {
1654                    Cmd::SetClipPush(r) => {
1655                        let top = clip_stack.last().copied().unwrap_or(root_clip);
1656
1657                        let next = intersect(top, r);
1658
1659                        clip_stack.push(next);
1660                        let (x, y, w, h) = to_scissor(&next, self.config.width, self.config.height);
1661                        rpass.set_scissor_rect(x, y, w, h);
1662                    }
1663                    Cmd::SetClipPop => {
1664                        if !clip_stack.is_empty() {
1665                            clip_stack.pop();
1666                        } else {
1667                            log::warn!("PopClip with empty stack");
1668                        }
1669
1670                        let top = clip_stack.last().copied().unwrap_or(root_clip);
1671                        let (x, y, w, h) = to_scissor(&top, self.config.width, self.config.height);
1672                        rpass.set_scissor_rect(x, y, w, h);
1673                    }
1674
1675                    Cmd::Rect { off, cnt: n } => {
1676                        rpass.set_pipeline(&self.rect_pipeline);
1677                        let bytes = (n as u64) * std::mem::size_of::<RectInstance>() as u64;
1678                        rpass.set_vertex_buffer(0, self.ring_rect.buf.slice(off..off + bytes));
1679                        rpass.draw(0..6, 0..n);
1680                    }
1681                    Cmd::Border { off, cnt: n } => {
1682                        rpass.set_pipeline(&self.border_pipeline);
1683                        let bytes = (n as u64) * std::mem::size_of::<BorderInstance>() as u64;
1684                        rpass.set_vertex_buffer(0, self.ring_border.buf.slice(off..off + bytes));
1685                        rpass.draw(0..6, 0..n);
1686                    }
1687                    Cmd::GlyphsMask { off, cnt: n } => {
1688                        rpass.set_pipeline(&self.text_pipeline_mask);
1689                        rpass.set_bind_group(0, &bind_mask, &[]);
1690                        let bytes = (n as u64) * std::mem::size_of::<GlyphInstance>() as u64;
1691                        rpass
1692                            .set_vertex_buffer(0, self.ring_glyph_mask.buf.slice(off..off + bytes));
1693                        rpass.draw(0..6, 0..n);
1694                    }
1695                    Cmd::GlyphsColor { off, cnt: n } => {
1696                        rpass.set_pipeline(&self.text_pipeline_color);
1697                        rpass.set_bind_group(0, &bind_color, &[]);
1698                        let bytes = (n as u64) * std::mem::size_of::<GlyphInstance>() as u64;
1699                        rpass.set_vertex_buffer(
1700                            0,
1701                            self.ring_glyph_color.buf.slice(off..off + bytes),
1702                        );
1703                        rpass.draw(0..6, 0..n);
1704                    }
1705                    Cmd::Image {
1706                        off,
1707                        cnt: n,
1708                        handle,
1709                    } => {
1710                        // Use the same color text pipeline; bind the per-image texture
1711                        if let Some(tex) = self.images.get(&handle) {
1712                            rpass.set_pipeline(&self.text_pipeline_color);
1713                            rpass.set_bind_group(0, &tex.bind, &[]);
1714                            let bytes = (n as u64) * std::mem::size_of::<GlyphInstance>() as u64;
1715                            rpass.set_vertex_buffer(
1716                                0,
1717                                self.ring_glyph_color.buf.slice(off..off + bytes),
1718                            );
1719                            rpass.draw(0..6, 0..n);
1720                        } else {
1721                            log::warn!("Image handle {} not found; skipping draw", handle);
1722                        }
1723                    }
1724                    Cmd::Ellipse { off, cnt: n } => {
1725                        rpass.set_pipeline(&self.ellipse_pipeline);
1726                        let bytes = (n as u64) * std::mem::size_of::<EllipseInstance>() as u64;
1727                        rpass.set_vertex_buffer(0, self.ring_ellipse.buf.slice(off..off + bytes));
1728                        rpass.draw(0..6, 0..n);
1729                    }
1730                    Cmd::EllipseBorder { off, cnt: n } => {
1731                        rpass.set_pipeline(&self.ellipse_border_pipeline);
1732                        let bytes =
1733                            (n as u64) * std::mem::size_of::<EllipseBorderInstance>() as u64;
1734                        rpass.set_vertex_buffer(
1735                            0,
1736                            self.ring_ellipse_border.buf.slice(off..off + bytes),
1737                        );
1738                        rpass.draw(0..6, 0..n);
1739                    }
1740                    Cmd::PushTransform(_transform) => {}
1741                    Cmd::PopTransform => {}
1742                }
1743            }
1744        }
1745
1746        self.queue.submit(std::iter::once(encoder.finish()));
1747        if let Err(e) = catch_unwind(AssertUnwindSafe(|| frame.present())) {
1748            log::warn!("frame.present panicked: {:?}", e);
1749        }
1750    }
1751}
1752
1753fn intersect(a: repose_core::Rect, b: repose_core::Rect) -> repose_core::Rect {
1754    let x0 = a.x.max(b.x);
1755    let y0 = a.y.max(b.y);
1756    let x1 = (a.x + a.w).min(b.x + b.w);
1757    let y1 = (a.y + a.h).min(b.y + b.h);
1758    repose_core::Rect {
1759        x: x0,
1760        y: y0,
1761        w: (x1 - x0).max(0.0),
1762        h: (y1 - y0).max(0.0),
1763    }
1764}