repose_render_wgpu/
lib.rs

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