Skip to main content

truce_gpu/
backend.rs

1//! GPU rendering backend using wgpu.
2//!
3//! Renders via Metal (macOS), DX12 (Windows), or Vulkan (Linux).
4//! Uses immediate-mode geometry: each frame rebuilds the vertex buffer
5//! from `RenderBackend` draw calls, then flushes in `present()`.
6
7use std::collections::HashMap;
8#[cfg(target_os = "macos")]
9use std::ffi::c_void;
10use std::sync::Arc;
11
12use bytemuck::{Pod, Zeroable};
13use lyon_tessellation::geom::point;
14use lyon_tessellation::path::Path;
15use lyon_tessellation::{
16    BuffersBuilder, FillOptions, FillTessellator, FillVertex, StrokeOptions, StrokeTessellator,
17    StrokeVertex, VertexBuffers,
18};
19use wgpu::util::DeviceExt;
20
21use truce_core::cast::len_u32;
22use truce_gui_types::render::{ImageId, RenderBackend};
23use truce_gui_types::theme::Color;
24
25// ---------------------------------------------------------------------------
26// Vertex format
27// ---------------------------------------------------------------------------
28
29#[repr(C)]
30#[derive(Copy, Clone, Pod, Zeroable)]
31struct Vertex {
32    position: [f32; 2],
33    color: [f32; 4],
34    uv: [f32; 2],
35    /// 0.0 = solid color; 1.0 = glyph atlas (R8, .r is alpha);
36    /// 2.0 = RGBA image (tex * color, both premultiplied).
37    tex_mode: f32,
38    _pad: f32,
39}
40
41impl Vertex {
42    fn solid(x: f32, y: f32, color: [f32; 4]) -> Self {
43        Self {
44            position: [x, y],
45            color,
46            uv: [0.0, 0.0],
47            tex_mode: 0.0,
48            _pad: 0.0,
49        }
50    }
51
52    fn glyph(x: f32, y: f32, color: [f32; 4], u: f32, v: f32) -> Self {
53        Self {
54            position: [x, y],
55            color,
56            uv: [u, v],
57            tex_mode: 1.0,
58            _pad: 0.0,
59        }
60    }
61
62    fn image(x: f32, y: f32, color: [f32; 4], u: f32, v: f32) -> Self {
63        Self {
64            position: [x, y],
65            color,
66            uv: [u, v],
67            tex_mode: 2.0,
68            _pad: 0.0,
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Glyph atlas
75// ---------------------------------------------------------------------------
76
77const ATLAS_SIZE: u32 = 512;
78
79struct GlyphUV {
80    u0: f32,
81    v0: f32,
82    u1: f32,
83    v1: f32,
84    advance: f32,
85    width: f32,
86    height: f32,
87    y_offset: f32,
88}
89
90struct GlyphAtlas {
91    /// Shelf-packing state.
92    shelf_y: u32,
93    shelf_h: u32,
94    cursor_x: u32,
95    /// Cached glyph UVs keyed by (char, `size_tenths`).
96    glyphs: HashMap<(char, u32), GlyphUV>,
97    /// Pending pixel uploads: (x, y, w, h, data).
98    pending: Vec<(u32, u32, u32, u32, Vec<u8>)>,
99    /// Set when `ensure_glyph` couldn't fit a new glyph. The next call to
100    /// `WgpuBackend::clear` evicts the cache so subsequent frames can
101    /// re-rasterize from scratch - never mid-frame, which would invalidate
102    /// UVs the current frame's vertex buffer already references.
103    overflow_pending: bool,
104}
105
106impl GlyphAtlas {
107    fn new() -> Self {
108        Self {
109            shelf_y: 0,
110            shelf_h: 0,
111            cursor_x: 0,
112            glyphs: HashMap::new(),
113            pending: Vec::new(),
114            overflow_pending: false,
115        }
116    }
117
118    fn clear(&mut self) {
119        self.shelf_y = 0;
120        self.shelf_h = 0;
121        self.cursor_x = 0;
122        self.glyphs.clear();
123        self.overflow_pending = false;
124    }
125
126    /// Try to place a glyph in the atlas. On overflow, sets
127    /// `overflow_pending` and returns without inserting; caller must
128    /// tolerate a missing entry for the rest of this frame. Subsequent
129    /// frames clear the atlas at frame start and re-rasterize.
130    // Quantized cache key - `(size * 10.0) as u32` deliberately
131    // truncates to one decimal place for HashMap stability. Atlas
132    // dimensions and glyph metrics fit comfortably in f32.
133    #[allow(
134        clippy::cast_possible_truncation,
135        clippy::cast_sign_loss,
136        clippy::cast_precision_loss
137    )]
138    fn ensure_glyph(&mut self, font: &truce_font::raster::FontRaster, ch: char, size: f32) {
139        let key = (ch, (size * 10.0) as u32);
140        if self.glyphs.contains_key(&key) {
141            return;
142        }
143        let glyph = font.rasterize(ch, size);
144        let (gw, gh) = (glyph.width, glyph.height);
145
146        // Shelf-pack: does it fit on the current shelf?
147        if self.cursor_x + gw > ATLAS_SIZE {
148            self.shelf_y += self.shelf_h;
149            self.shelf_h = 0;
150            self.cursor_x = 0;
151        }
152        if self.shelf_y + gh > ATLAS_SIZE {
153            // Atlas full. Calling self.clear() here would wipe entries
154            // the current frame's vertex buffer still references and
155            // evict glyphs that earlier draw_text iterations expect to
156            // look up - at best wrong UVs, at worst a HashMap lookup
157            // panic. Defer the clear to the next frame boundary.
158            self.overflow_pending = true;
159            return;
160        }
161
162        let x = self.cursor_x;
163        let y = self.shelf_y;
164        self.cursor_x += gw;
165        self.shelf_h = self.shelf_h.max(gh);
166
167        let u0 = x as f32 / ATLAS_SIZE as f32;
168        let v0 = y as f32 / ATLAS_SIZE as f32;
169        let u1 = (x + gw) as f32 / ATLAS_SIZE as f32;
170        let v1 = (y + gh) as f32 / ATLAS_SIZE as f32;
171
172        self.pending.push((x, y, gw, gh, glyph.coverage));
173
174        self.glyphs.insert(
175            key,
176            GlyphUV {
177                u0,
178                v0,
179                u1,
180                v1,
181                advance: glyph.advance,
182                width: gw as f32,
183                height: gh as f32,
184                y_offset: glyph.y_min,
185            },
186        );
187    }
188}
189
190// ---------------------------------------------------------------------------
191// WGSL shader
192// ---------------------------------------------------------------------------
193
194const SHADER_SRC: &str = r"
195struct Viewport {
196    transform: mat4x4<f32>,
197};
198@group(0) @binding(0) var<uniform> viewport: Viewport;
199
200// At group 1 slot 0 we bind either the R8 glyph atlas (tex_mode == 1.0)
201// or an RGBA image (tex_mode == 2.0). For solid draws (tex_mode == 0.0)
202// the texture is not sampled; any compatible binding works.
203@group(1) @binding(0) var main_tex: texture_2d<f32>;
204@group(1) @binding(1) var main_samp: sampler;
205
206struct VsIn {
207    @location(0) position: vec2<f32>,
208    @location(1) color: vec4<f32>,
209    @location(2) uv: vec2<f32>,
210    @location(3) tex_mode: f32,
211};
212
213struct VsOut {
214    @builtin(position) clip_pos: vec4<f32>,
215    @location(0) color: vec4<f32>,
216    @location(1) uv: vec2<f32>,
217    @location(2) tex_mode: f32,
218};
219
220@vertex
221fn vs_main(in: VsIn) -> VsOut {
222    var out: VsOut;
223    out.clip_pos = viewport.transform * vec4<f32>(in.position, 0.0, 1.0);
224    out.color = in.color;
225    out.uv = in.uv;
226    out.tex_mode = in.tex_mode;
227    return out;
228}
229
230@fragment
231fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
232    let tex = textureSample(main_tex, main_samp, in.uv);
233    if (in.tex_mode > 1.5) {
234        // Image: RGBA texture tinted by vertex color. Both sides are
235        // treated as premultiplied; output is premultiplied.
236        return tex * in.color;
237    }
238    // Glyph (tex_mode == 1) uses .r as coverage; solid (tex_mode == 0)
239    // bypasses the sample. mix(1.0, tex.r, tex_mode) handles both.
240    let alpha = mix(1.0, tex.r, in.tex_mode);
241    return vec4<f32>(in.color.rgb * in.color.a * alpha, in.color.a * alpha);
242}
243";
244
245// ---------------------------------------------------------------------------
246// WgpuBackend
247// ---------------------------------------------------------------------------
248
249/// One image registered via `register_image`. Owns its wgpu texture
250/// (kept alive so the bind group's view stays valid) and the bind group.
251struct ImageEntry {
252    _texture: wgpu::Texture,
253    bind_group: wgpu::BindGroup,
254}
255
256/// A contiguous run of indices that share a single bind group.
257///
258/// `image` is `None` for primitives and glyphs (which use the atlas bind
259/// group) and `Some(id)` for RGBA image draws. Batches are closed and a
260/// new one started whenever the target bind group changes.
261#[derive(Clone, Copy)]
262struct DrawBatch {
263    index_start: u32,
264    image: Option<ImageId>,
265}
266
267/// GPU-based rendering backend.
268///
269/// Creates a wgpu device and surface from a platform-provided Metal layer
270/// (macOS) or window handle. Implements `RenderBackend` by accumulating
271/// geometry per frame, then flushing it in `present()`.
272pub struct WgpuBackend {
273    device: Arc<wgpu::Device>,
274    queue: Arc<wgpu::Queue>,
275    /// None for headless mode (snapshot testing), when using the
276    /// standalone `new()` constructor (caller owns the surface), or
277    /// when a pump owns the surface (see [`Self::pump`]). When
278    /// present, `present()` renders to the surface frame.
279    surface: Option<wgpu::Surface<'static>>,
280    surface_config: Option<wgpu::SurfaceConfiguration>,
281    /// When set (windowed editors via `pump_init` + `set_pump`), the
282    /// surface lives with a `crate::pump::SurfacePump` and acquire /
283    /// configure / present route through this client - on Windows
284    /// that keeps every blocking swapchain call off the host's GUI
285    /// thread. `surface` is `None` in this mode; `surface_config`
286    /// stays as the local size/format bookkeeping copy.
287    #[cfg(not(target_os = "ios"))]
288    pump: Option<crate::pump::PumpClient>,
289    pipeline: wgpu::RenderPipeline,
290    /// Format of the eventual color target. Used to (re)build the MSAA
291    /// texture on resize / `begin_frame` size changes.
292    target_format: wgpu::TextureFormat,
293    msaa_texture: wgpu::TextureView,
294    /// Current physical dimensions of the MSAA texture. `begin_frame`
295    /// rebuilds the texture if these no longer match the target view.
296    msaa_width: u32,
297    msaa_height: u32,
298    /// How long the last `present` blocked in the swapchain acquire
299    /// (`get_current_texture`). The acquire waits for the compositor
300    /// to free a frame slot; editor handlers read this to pace paints
301    /// so the wait doesn't park the host's GUI thread.
302    last_acquire_wait: std::time::Duration,
303    vertices: Vec<Vertex>,
304    indices: Vec<u32>,
305    /// Ordered list of bind-group switches within the current frame. Always
306    /// starts with one batch referencing the atlas; additional entries are
307    /// appended when `draw_image` needs to switch to an image bind group.
308    batches: Vec<DrawBatch>,
309    glyph_atlas: GlyphAtlas,
310    font: truce_font::raster::FontRaster,
311    atlas_texture: wgpu::Texture,
312    atlas_bind_group: wgpu::BindGroup,
313    /// Layout shared between the atlas bind group and every per-image
314    /// bind group (same `texture2d<f32>` + sampler layout).
315    tex_bind_group_layout: wgpu::BindGroupLayout,
316    /// Shared linear sampler used for both the glyph atlas and images.
317    sampler: wgpu::Sampler,
318    /// Registered images indexed by `ImageId.0`. `None` = free slot.
319    images: Vec<Option<ImageEntry>>,
320    viewport_buffer: wgpu::Buffer,
321    viewport_bind_group: wgpu::BindGroup,
322    /// Pending clear request for the next render pass. `Some(c)` means
323    /// the next `finish()` clears the target to `c`; `None` means it
324    /// loads existing contents (the common case when widgets overlay a
325    /// custom render). Set by [`RenderBackend::clear`] and consumed by
326    /// `finish()` / `present()`.
327    clear_color: Option<wgpu::Color>,
328    /// Fallback clear color for the present path (which can't `Load` -
329    /// the swap-chain texture would surface stale prior-frame content).
330    /// Used when `clear_color` is `None`. The Metal layer path defaults
331    /// to `TRANSPARENT` so the host's compositor sees through; other
332    /// backends default to `BLACK`.
333    present_clear_default: wgpu::Color,
334    width: u32,
335    height: u32,
336    /// Scale factor: logical points × scale = physical pixels.
337    scale: f32,
338}
339
340fn ortho_matrix(w: f32, h: f32) -> [[f32; 4]; 4] {
341    [
342        [2.0 / w, 0.0, 0.0, 0.0],
343        [0.0, -2.0 / h, 0.0, 0.0],
344        [0.0, 0.0, 1.0, 0.0],
345        [-1.0, 1.0, 0.0, 1.0],
346    ]
347}
348
349impl WgpuBackend {
350    /// Create a GPU backend from a pre-created wgpu surface.
351    ///
352    /// `logical_w` and `logical_h` are in logical points. `scale` is the
353    /// display scale factor (2.0 on Retina). The surface is configured at
354    /// `logical × scale` physical pixels.
355    ///
356    /// # Panics
357    ///
358    /// Panics if the embedded font fails to parse (a bug in the
359    /// bundled font asset, never user input).
360    pub fn from_surface(
361        instance: &wgpu::Instance,
362        surface: wgpu::Surface<'static>,
363        logical_w: u32,
364        logical_h: u32,
365        scale: f32,
366    ) -> Option<Self> {
367        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
368            power_preference: wgpu::PowerPreference::HighPerformance,
369            compatible_surface: Some(&surface),
370            force_fallback_adapter: false,
371        }))
372        .ok()?;
373        let (mut backend, device, config) =
374            Self::pump_init(&adapter, &surface, logical_w, logical_h, scale)?;
375        surface.configure(&device, &config);
376        backend.surface = Some(surface);
377        Some(backend)
378    }
379
380    /// Everything downstream of device + surface-configuration
381    /// selection: pipeline, glyph atlas, viewport uniform, MSAA
382    /// target. Shared by [`Self::from_surface`] (surface owned by the
383    /// backend) and [`Self::pump_init`] (surface owned by the pump).
384    ///
385    /// # Panics
386    ///
387    /// Panics if the embedded font fails to parse (a bug in the
388    /// bundled font asset, never user input).
389    // Surface dimensions in pixels stay below 2^23, well within f32.
390    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
391    fn from_parts(
392        device: Arc<wgpu::Device>,
393        queue: Arc<wgpu::Queue>,
394        surface: Option<wgpu::Surface<'static>>,
395        surface_config: wgpu::SurfaceConfiguration,
396        width: u32,
397        height: u32,
398        scale: f32,
399    ) -> Self {
400        let surface_format = surface_config.format;
401
402        // MSAA texture
403        let msaa_texture = Self::create_msaa_texture(&device, &surface_config);
404
405        // Shader
406        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
407            label: Some("truce-gpu-shader"),
408            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
409        });
410
411        // Viewport uniform
412        let matrix = ortho_matrix(width as f32, height as f32);
413        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
414            label: Some("viewport"),
415            contents: bytemuck::cast_slice(&matrix),
416            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
417        });
418
419        let viewport_bind_group_layout =
420            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
421                label: Some("viewport-layout"),
422                entries: &[wgpu::BindGroupLayoutEntry {
423                    binding: 0,
424                    visibility: wgpu::ShaderStages::VERTEX,
425                    ty: wgpu::BindingType::Buffer {
426                        ty: wgpu::BufferBindingType::Uniform,
427                        has_dynamic_offset: false,
428                        min_binding_size: None,
429                    },
430                    count: None,
431                }],
432            });
433
434        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
435            label: Some("viewport-bg"),
436            layout: &viewport_bind_group_layout,
437            entries: &[wgpu::BindGroupEntry {
438                binding: 0,
439                resource: viewport_buffer.as_entire_binding(),
440            }],
441        });
442
443        // Atlas texture (R8Unorm, 512x512)
444        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
445            label: Some("glyph-atlas"),
446            size: wgpu::Extent3d {
447                width: ATLAS_SIZE,
448                height: ATLAS_SIZE,
449                depth_or_array_layers: 1,
450            },
451            mip_level_count: 1,
452            sample_count: 1,
453            dimension: wgpu::TextureDimension::D2,
454            format: wgpu::TextureFormat::R8Unorm,
455            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
456            view_formats: &[],
457        });
458
459        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
460        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
461            mag_filter: wgpu::FilterMode::Linear,
462            min_filter: wgpu::FilterMode::Linear,
463            ..Default::default()
464        });
465
466        let tex_bind_group_layout =
467            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
468                label: Some("tex-layout"),
469                entries: &[
470                    wgpu::BindGroupLayoutEntry {
471                        binding: 0,
472                        visibility: wgpu::ShaderStages::FRAGMENT,
473                        ty: wgpu::BindingType::Texture {
474                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
475                            view_dimension: wgpu::TextureViewDimension::D2,
476                            multisampled: false,
477                        },
478                        count: None,
479                    },
480                    wgpu::BindGroupLayoutEntry {
481                        binding: 1,
482                        visibility: wgpu::ShaderStages::FRAGMENT,
483                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
484                        count: None,
485                    },
486                ],
487            });
488
489        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
490            label: Some("atlas-bg"),
491            layout: &tex_bind_group_layout,
492            entries: &[
493                wgpu::BindGroupEntry {
494                    binding: 0,
495                    resource: wgpu::BindingResource::TextureView(&atlas_view),
496                },
497                wgpu::BindGroupEntry {
498                    binding: 1,
499                    resource: wgpu::BindingResource::Sampler(&sampler),
500                },
501            ],
502        });
503
504        // Pipeline
505        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
506            label: Some("truce-gpu-pipeline-layout"),
507            bind_group_layouts: &[
508                Some(&viewport_bind_group_layout),
509                Some(&tex_bind_group_layout),
510            ],
511            immediate_size: 0,
512        });
513
514        let vertex_layout = wgpu::VertexBufferLayout {
515            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
516            step_mode: wgpu::VertexStepMode::Vertex,
517            attributes: &[
518                // position
519                wgpu::VertexAttribute {
520                    offset: 0,
521                    shader_location: 0,
522                    format: wgpu::VertexFormat::Float32x2,
523                },
524                // color
525                wgpu::VertexAttribute {
526                    offset: 8,
527                    shader_location: 1,
528                    format: wgpu::VertexFormat::Float32x4,
529                },
530                // uv
531                wgpu::VertexAttribute {
532                    offset: 24,
533                    shader_location: 2,
534                    format: wgpu::VertexFormat::Float32x2,
535                },
536                // tex_mode
537                wgpu::VertexAttribute {
538                    offset: 32,
539                    shader_location: 3,
540                    format: wgpu::VertexFormat::Float32,
541                },
542            ],
543        };
544
545        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
546            label: Some("truce-gpu-pipeline"),
547            layout: Some(&pipeline_layout),
548            vertex: wgpu::VertexState {
549                module: &shader,
550                entry_point: Some("vs_main"),
551                buffers: &[vertex_layout],
552                compilation_options: wgpu::PipelineCompilationOptions::default(),
553            },
554            fragment: Some(wgpu::FragmentState {
555                module: &shader,
556                entry_point: Some("fs_main"),
557                targets: &[Some(wgpu::ColorTargetState {
558                    format: surface_format,
559                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
560                    write_mask: wgpu::ColorWrites::ALL,
561                })],
562                compilation_options: wgpu::PipelineCompilationOptions::default(),
563            }),
564            primitive: wgpu::PrimitiveState {
565                topology: wgpu::PrimitiveTopology::TriangleList,
566                strip_index_format: None,
567                front_face: wgpu::FrontFace::Ccw,
568                cull_mode: None,
569                unclipped_depth: false,
570                polygon_mode: wgpu::PolygonMode::Fill,
571                conservative: false,
572            },
573            depth_stencil: None,
574            multisample: wgpu::MultisampleState {
575                count: 4,
576                mask: !0,
577                alpha_to_coverage_enabled: false,
578            },
579            multiview_mask: None,
580            cache: None,
581        });
582
583        // Font
584        let font = truce_font::raster::FontRaster::new();
585
586        Self {
587            device,
588            queue,
589            surface,
590            surface_config: Some(surface_config),
591            #[cfg(not(target_os = "ios"))]
592            pump: None,
593            pipeline,
594            target_format: surface_format,
595            msaa_texture,
596            msaa_width: width,
597            msaa_height: height,
598            last_acquire_wait: std::time::Duration::ZERO,
599            vertices: Vec::with_capacity(4096),
600            indices: Vec::with_capacity(8192),
601            batches: Vec::new(),
602            glyph_atlas: GlyphAtlas::new(),
603            font,
604            atlas_texture,
605            atlas_bind_group,
606            tex_bind_group_layout,
607            sampler,
608            images: Vec::new(),
609            viewport_buffer,
610            viewport_bind_group,
611            clear_color: None,
612            present_clear_default: wgpu::Color::BLACK,
613            width,
614            height,
615            scale,
616        }
617    }
618
619    /// Device + surface-configuration selection given an adapter, for
620    /// a surface the backend does NOT take ownership of. Used by
621    /// [`Self::from_surface`] (which then attaches the surface) and by
622    /// `crate::pump::SurfacePump` init closures, where the surface
623    /// stays with the pump: acquire / configure / present route
624    /// through the pump's client, which on Windows runs them on the
625    /// pump thread instead of the host's GUI thread. Pump users call
626    /// [`Self::set_pump`] with the pump's client after adopting the
627    /// backend. Does not configure the surface.
628    pub fn pump_init(
629        adapter: &wgpu::Adapter,
630        surface: &wgpu::Surface<'static>,
631        logical_w: u32,
632        logical_h: u32,
633        scale: f32,
634    ) -> Option<(Self, wgpu::Device, wgpu::SurfaceConfiguration)> {
635        // Request what the adapter actually supports rather than the
636        // fixed `downlevel_defaults` cap (2048 max texture dim). On a
637        // desktop GPU that's typically 8192-16384, which is needed for
638        // any editor whose Retina-physical canvas exceeds 2048px on
639        // either axis (the GUI Zoo's tall layouts). The defensive
640        // clamp below still guards against requesting a surface
641        // larger than whatever the device ended up granting.
642        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
643            label: Some("truce-gpu"),
644            required_features: wgpu::Features::empty(),
645            required_limits: adapter.limits(),
646            experimental_features: wgpu::ExperimentalFeatures::default(),
647            memory_hints: wgpu::MemoryHints::Performance,
648            trace: wgpu::Trace::Off,
649        }))
650        .ok()?;
651        let plain_device = device.clone();
652        let device = Arc::new(device);
653        let queue = Arc::new(queue);
654
655        let max_dim = device.limits().max_texture_dimension_2d.max(1);
656        let width = truce_gui_types::to_physical_px(logical_w, f64::from(scale)).clamp(1, max_dim);
657        let height = truce_gui_types::to_physical_px(logical_h, f64::from(scale)).clamp(1, max_dim);
658
659        // Prefer `Rgba8Unorm` so the surface format matches
660        // `read_pixels` and the headless screenshot path; fall back to
661        // any non-sRGB format the surface advertises, then to whatever
662        // the surface lists first. Keeping the format aligned across
663        // windowed and headless paths means the same shader-side
664        // gamma/blend assumptions hold.
665        let surface_caps = surface.get_capabilities(adapter);
666        let surface_format = surface_caps
667            .formats
668            .iter()
669            .find(|f| **f == wgpu::TextureFormat::Rgba8Unorm)
670            .or_else(|| surface_caps.formats.iter().find(|f| !f.is_srgb()))
671            .copied()
672            .unwrap_or(surface_caps.formats[0]);
673
674        let surface_config = wgpu::SurfaceConfiguration {
675            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
676            format: surface_format,
677            width,
678            height,
679            // Windows: a Fifo (AutoVsync) present blocks when the
680            // child-window swapchain backs up - freezing the host
681            // (REAPER) when it lands on the GUI thread and risking a
682            // GPU-watchdog (TDR) hang. Non-blocking present there;
683            // elsewhere keeps vsync.
684            #[cfg(target_os = "windows")]
685            present_mode: wgpu::PresentMode::AutoNoVsync,
686            #[cfg(not(target_os = "windows"))]
687            present_mode: wgpu::PresentMode::AutoVsync,
688            desired_maximum_frame_latency: 2,
689            alpha_mode: wgpu::CompositeAlphaMode::Auto,
690            view_formats: vec![],
691        };
692
693        let backend = Self::from_parts(
694            device,
695            queue,
696            None,
697            surface_config.clone(),
698            width,
699            height,
700            scale,
701        );
702        Some((backend, plain_device, surface_config))
703    }
704
705    /// Attach the pump client the backend's acquire / configure /
706    /// present calls route through. Pair of [`Self::pump_init`].
707    #[cfg(not(target_os = "ios"))]
708    pub fn set_pump(&mut self, client: crate::pump::PumpClient) {
709        self.pump = Some(client);
710    }
711
712    /// Create a GPU backend from a raw `CAMetalLayer` pointer (macOS).
713    ///
714    /// `logical_w` / `logical_h` are in logical points; `scale` is the
715    /// layer's `contentsScale` (2.0 on Retina). The surface is
716    /// configured at `logical × scale` physical pixels, matching the
717    /// contract of [`Self::from_surface`].
718    ///
719    /// # Safety
720    /// `metal_layer` must be a valid `CAMetalLayer*` that outlives the backend.
721    #[cfg(target_os = "macos")]
722    pub unsafe fn from_metal_layer(
723        metal_layer: *mut c_void,
724        logical_w: u32,
725        logical_h: u32,
726        scale: f32,
727    ) -> Option<Self> {
728        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
729        desc.backends = wgpu::Backends::METAL;
730        let instance = wgpu::Instance::new(desc);
731
732        let surface = unsafe {
733            instance
734                .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(metal_layer))
735        }
736        .ok()?;
737
738        Self::from_surface(&instance, surface, logical_w, logical_h, scale)
739    }
740
741    /// Build a standalone `WgpuBackend` that records into encoders
742    /// supplied per-frame by the caller.
743    ///
744    /// Unlike [`Self::from_surface`] / `from_metal_layer` / the
745    /// pump path ([`Self::pump_init`]), this constructor does **not**
746    /// own or reach a `wgpu::Surface` or manage
747    /// frame acquisition. The caller is expected to have its own render
748    /// loop, allocate command encoders, and present - this backend only
749    /// supplies the 2D widget pipeline, glyph atlas, and lyon-tessellated
750    /// primitive recording.
751    ///
752    /// Usage:
753    ///
754    /// ```ignore
755    /// let mut backend = WgpuBackend::new(
756    ///     device.clone(), queue.clone(),
757    ///     target_format, max_w, max_h,
758    /// ).expect("backend init");
759    ///
760    /// // per-frame, after the caller has drawn its own content into `view`:
761    /// backend.begin_frame(w, h);
762    /// truce_gui::widgets::draw(&mut backend, &layout, &theme, &snap, &mut state);
763    /// backend.finish(&mut encoder, &view);
764    /// // caller submits encoder + presents.
765    /// ```
766    ///
767    /// `max_logical_w` / `max_logical_h` are in logical points; `scale`
768    /// is the display scale factor (2.0 on Retina, 1.0 otherwise). The
769    /// MSAA texture is seeded at `logical × scale` physical pixels; if
770    /// a subsequent `begin_frame(logical_w, logical_h)` exceeds the
771    /// seed, the MSAA texture is reallocated transparently.
772    ///
773    /// Matches the coordinate contract of [`Self::from_surface`]:
774    /// draw calls and event coordinates are logical
775    /// points; the backend multiplies by `scale` internally when
776    /// rasterizing.
777    ///
778    /// # Panics
779    ///
780    /// Panics if the embedded font fails to parse (bundled-asset
781    /// bug, never user input).
782    // Surface dimensions in pixels stay below 2^23, well within f32.
783    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
784    #[must_use]
785    pub fn new(
786        device: Arc<wgpu::Device>,
787        queue: Arc<wgpu::Queue>,
788        target_format: wgpu::TextureFormat,
789        max_logical_w: u32,
790        max_logical_h: u32,
791        scale: f32,
792    ) -> Option<Self> {
793        let scale = scale.max(0.0);
794        let width = truce_gui_types::to_physical_px(max_logical_w, f64::from(scale));
795        let height = truce_gui_types::to_physical_px(max_logical_h, f64::from(scale));
796
797        // Shader
798        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
799            label: Some("truce-gpu-shader"),
800            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
801        });
802
803        // Viewport uniform
804        let matrix = ortho_matrix(width as f32, height as f32);
805        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
806            label: Some("viewport"),
807            contents: bytemuck::cast_slice(&matrix),
808            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
809        });
810
811        let viewport_bind_group_layout =
812            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
813                label: Some("viewport-layout"),
814                entries: &[wgpu::BindGroupLayoutEntry {
815                    binding: 0,
816                    visibility: wgpu::ShaderStages::VERTEX,
817                    ty: wgpu::BindingType::Buffer {
818                        ty: wgpu::BufferBindingType::Uniform,
819                        has_dynamic_offset: false,
820                        min_binding_size: None,
821                    },
822                    count: None,
823                }],
824            });
825
826        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
827            label: Some("viewport-bg"),
828            layout: &viewport_bind_group_layout,
829            entries: &[wgpu::BindGroupEntry {
830                binding: 0,
831                resource: viewport_buffer.as_entire_binding(),
832            }],
833        });
834
835        // Glyph atlas
836        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
837            label: Some("glyph-atlas"),
838            size: wgpu::Extent3d {
839                width: ATLAS_SIZE,
840                height: ATLAS_SIZE,
841                depth_or_array_layers: 1,
842            },
843            mip_level_count: 1,
844            sample_count: 1,
845            dimension: wgpu::TextureDimension::D2,
846            format: wgpu::TextureFormat::R8Unorm,
847            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
848            view_formats: &[],
849        });
850        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
851        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
852            mag_filter: wgpu::FilterMode::Linear,
853            min_filter: wgpu::FilterMode::Linear,
854            ..Default::default()
855        });
856        let tex_bind_group_layout =
857            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
858                label: Some("tex-layout"),
859                entries: &[
860                    wgpu::BindGroupLayoutEntry {
861                        binding: 0,
862                        visibility: wgpu::ShaderStages::FRAGMENT,
863                        ty: wgpu::BindingType::Texture {
864                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
865                            view_dimension: wgpu::TextureViewDimension::D2,
866                            multisampled: false,
867                        },
868                        count: None,
869                    },
870                    wgpu::BindGroupLayoutEntry {
871                        binding: 1,
872                        visibility: wgpu::ShaderStages::FRAGMENT,
873                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
874                        count: None,
875                    },
876                ],
877            });
878        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
879            label: Some("atlas-bg"),
880            layout: &tex_bind_group_layout,
881            entries: &[
882                wgpu::BindGroupEntry {
883                    binding: 0,
884                    resource: wgpu::BindingResource::TextureView(&atlas_view),
885                },
886                wgpu::BindGroupEntry {
887                    binding: 1,
888                    resource: wgpu::BindingResource::Sampler(&sampler),
889                },
890            ],
891        });
892
893        // Pipeline
894        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
895            label: Some("truce-gpu-pipeline-layout"),
896            bind_group_layouts: &[
897                Some(&viewport_bind_group_layout),
898                Some(&tex_bind_group_layout),
899            ],
900            immediate_size: 0,
901        });
902
903        let vertex_layout = wgpu::VertexBufferLayout {
904            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
905            step_mode: wgpu::VertexStepMode::Vertex,
906            attributes: &[
907                wgpu::VertexAttribute {
908                    offset: 0,
909                    shader_location: 0,
910                    format: wgpu::VertexFormat::Float32x2,
911                },
912                wgpu::VertexAttribute {
913                    offset: 8,
914                    shader_location: 1,
915                    format: wgpu::VertexFormat::Float32x4,
916                },
917                wgpu::VertexAttribute {
918                    offset: 24,
919                    shader_location: 2,
920                    format: wgpu::VertexFormat::Float32x2,
921                },
922                wgpu::VertexAttribute {
923                    offset: 32,
924                    shader_location: 3,
925                    format: wgpu::VertexFormat::Float32,
926                },
927            ],
928        };
929
930        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
931            label: Some("truce-gpu-pipeline"),
932            layout: Some(&pipeline_layout),
933            vertex: wgpu::VertexState {
934                module: &shader,
935                entry_point: Some("vs_main"),
936                buffers: &[vertex_layout],
937                compilation_options: wgpu::PipelineCompilationOptions::default(),
938            },
939            fragment: Some(wgpu::FragmentState {
940                module: &shader,
941                entry_point: Some("fs_main"),
942                targets: &[Some(wgpu::ColorTargetState {
943                    format: target_format,
944                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
945                    write_mask: wgpu::ColorWrites::ALL,
946                })],
947                compilation_options: wgpu::PipelineCompilationOptions::default(),
948            }),
949            primitive: wgpu::PrimitiveState {
950                topology: wgpu::PrimitiveTopology::TriangleList,
951                ..Default::default()
952            },
953            depth_stencil: None,
954            multisample: wgpu::MultisampleState {
955                count: 4,
956                mask: !0,
957                alpha_to_coverage_enabled: false,
958            },
959            multiview_mask: None,
960            cache: None,
961        });
962
963        // MSAA
964        let msaa_texture = Self::create_msaa_view(&device, target_format, width, height);
965
966        let font = truce_font::raster::FontRaster::new();
967
968        Some(Self {
969            device,
970            queue,
971            surface: None,
972            surface_config: None,
973            #[cfg(not(target_os = "ios"))]
974            pump: None,
975            pipeline,
976            target_format,
977            msaa_texture,
978            msaa_width: width,
979            msaa_height: height,
980            last_acquire_wait: std::time::Duration::ZERO,
981            vertices: Vec::with_capacity(4096),
982            indices: Vec::with_capacity(8192),
983            batches: Vec::new(),
984            glyph_atlas: GlyphAtlas::new(),
985            font,
986            atlas_texture,
987            atlas_bind_group,
988            tex_bind_group_layout,
989            sampler,
990            images: Vec::new(),
991            viewport_buffer,
992            viewport_bind_group,
993            clear_color: None,
994            present_clear_default: wgpu::Color::TRANSPARENT,
995            width,
996            height,
997            scale,
998        })
999    }
1000
1001    /// Prepare for recording a frame of `logical_w × logical_h` logical
1002    /// points. The MSAA target and ortho matrix are sized at
1003    /// `logical × self.scale()` physical pixels; widget draw calls use
1004    /// logical coordinates.
1005    ///
1006    /// Resets accumulated geometry and the clear flag. Rebuilds the MSAA
1007    /// texture if the physical size differs from the previous frame.
1008    ///
1009    /// Only meaningful when the backend was built via [`Self::new`]; the
1010    /// surface-owning constructors drive their own frame lifecycle.
1011    #[allow(clippy::cast_precision_loss)]
1012    pub fn begin_frame(&mut self, logical_w: u32, logical_h: u32) {
1013        let phys_w = truce_gui_types::to_physical_px(logical_w, f64::from(self.scale));
1014        let phys_h = truce_gui_types::to_physical_px(logical_h, f64::from(self.scale));
1015        self.vertices.clear();
1016        self.indices.clear();
1017        self.batches.clear();
1018        self.clear_color = None;
1019
1020        if phys_w != self.width || phys_h != self.height {
1021            self.width = phys_w;
1022            self.height = phys_h;
1023            let matrix = ortho_matrix(phys_w as f32, phys_h as f32);
1024            self.queue
1025                .write_buffer(&self.viewport_buffer, 0, bytemuck::cast_slice(&matrix));
1026        }
1027
1028        if phys_w != self.msaa_width || phys_h != self.msaa_height {
1029            self.msaa_texture =
1030                Self::create_msaa_view(&self.device, self.target_format, phys_w, phys_h);
1031            self.msaa_width = phys_w;
1032            self.msaa_height = phys_h;
1033        }
1034    }
1035
1036    /// Display scale factor: `logical × scale = physical`. Callers
1037    /// sizing sibling GPU resources (e.g. an intermediate texture that
1038    /// the backend will resolve into) should use this to stay
1039    /// consistent with the backend's raster dimensions.
1040    pub fn scale(&self) -> f32 {
1041        self.scale
1042    }
1043
1044    /// Update the display scale factor. The next [`Self::resize`] (or
1045    /// [`Self::begin_frame`] in headless mode) recomputes physical
1046    /// dimensions and reconfigures the surface / MSAA target. Callers
1047    /// driving a windowed surface should follow with a `resize` so the
1048    /// `surface_config` picks up the new size on the same frame; the
1049    /// short-circuit in `resize` doesn't trigger because the scale
1050    /// change makes the new physical dims differ from the old.
1051    pub fn set_scale(&mut self, scale: f32) {
1052        if scale.is_finite() && scale > 0.0 {
1053            self.scale = scale;
1054        }
1055    }
1056
1057    /// Flush accumulated geometry into a single render pass on `view`,
1058    /// recorded into `encoder`. The caller retains ownership of both -
1059    /// this method neither submits the encoder nor calls `present()`.
1060    ///
1061    /// If `clear()` was called since the last `begin_frame`, the pass
1062    /// uses `LoadOp::Clear(clear_color)`; otherwise `LoadOp::Load` so
1063    /// any prior content in `view` is preserved (the common case when
1064    /// widgets overlay a custom render).
1065    pub fn finish(&mut self, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
1066        self.flush_atlas();
1067
1068        if self.indices.is_empty() {
1069            self.clear_color = None;
1070            return;
1071        }
1072
1073        let vertex_buffer = self
1074            .device
1075            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1076                label: Some("vertices"),
1077                contents: bytemuck::cast_slice(&self.vertices),
1078                usage: wgpu::BufferUsages::VERTEX,
1079            });
1080
1081        let index_buffer = self
1082            .device
1083            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1084                label: Some("indices"),
1085                contents: bytemuck::cast_slice(&self.indices),
1086                usage: wgpu::BufferUsages::INDEX,
1087            });
1088
1089        // MSAA load/store must agree across frames: if we plan to `Load`
1090        // next pass, this pass must `Store` (otherwise the next pass loads
1091        // undefined contents). With a `resolve_target` set, `Discard` is
1092        // standard for MSAA - but it's only well-defined when we also
1093        // `Clear` on entry, since a fresh load after a discard is UB.
1094        let (load, store) = match self.clear_color {
1095            Some(c) => (wgpu::LoadOp::Clear(c), wgpu::StoreOp::Discard),
1096            None => (wgpu::LoadOp::Load, wgpu::StoreOp::Store),
1097        };
1098
1099        {
1100            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1101                label: Some("truce-gpu-frame"),
1102                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1103                    view: &self.msaa_texture,
1104                    resolve_target: Some(view),
1105                    ops: wgpu::Operations { load, store },
1106                    depth_slice: None,
1107                })],
1108                depth_stencil_attachment: None,
1109                timestamp_writes: None,
1110                occlusion_query_set: None,
1111                multiview_mask: None,
1112            });
1113
1114            pass.set_pipeline(&self.pipeline);
1115            pass.set_bind_group(0, &self.viewport_bind_group, &[]);
1116            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1117            pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1118
1119            let total_indices = len_u32(self.indices.len());
1120            if self.batches.is_empty() {
1121                pass.set_bind_group(1, &self.atlas_bind_group, &[]);
1122                pass.draw_indexed(0..total_indices, 0, 0..1);
1123            } else {
1124                for i in 0..self.batches.len() {
1125                    let b = self.batches[i];
1126                    let end = self
1127                        .batches
1128                        .get(i + 1)
1129                        .map_or(total_indices, |n| n.index_start);
1130                    if end <= b.index_start {
1131                        continue;
1132                    }
1133                    let bg = match b.image {
1134                        None => &self.atlas_bind_group,
1135                        Some(img_id) => {
1136                            match self.images.get(img_id.0 as usize).and_then(|s| s.as_ref()) {
1137                                Some(entry) => &entry.bind_group,
1138                                None => continue,
1139                            }
1140                        }
1141                    };
1142                    pass.set_bind_group(1, bg, &[]);
1143                    pass.draw_indexed(b.index_start..end, 0, 0..1);
1144                }
1145            }
1146        }
1147
1148        self.clear_color = None;
1149    }
1150
1151    fn create_msaa_view(
1152        device: &wgpu::Device,
1153        format: wgpu::TextureFormat,
1154        width: u32,
1155        height: u32,
1156    ) -> wgpu::TextureView {
1157        let tex = device.create_texture(&wgpu::TextureDescriptor {
1158            label: Some("msaa"),
1159            size: wgpu::Extent3d {
1160                width,
1161                height,
1162                depth_or_array_layers: 1,
1163            },
1164            mip_level_count: 1,
1165            sample_count: 4,
1166            dimension: wgpu::TextureDimension::D2,
1167            format,
1168            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1169            view_formats: &[],
1170        });
1171        tex.create_view(&wgpu::TextureViewDescriptor::default())
1172    }
1173
1174    fn create_msaa_texture(
1175        device: &wgpu::Device,
1176        config: &wgpu::SurfaceConfiguration,
1177    ) -> wgpu::TextureView {
1178        let tex = device.create_texture(&wgpu::TextureDescriptor {
1179            label: Some("msaa"),
1180            size: wgpu::Extent3d {
1181                width: config.width,
1182                height: config.height,
1183                depth_or_array_layers: 1,
1184            },
1185            mip_level_count: 1,
1186            sample_count: 4,
1187            dimension: wgpu::TextureDimension::D2,
1188            format: config.format,
1189            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1190            view_formats: &[],
1191        });
1192        tex.create_view(&wgpu::TextureViewDescriptor::default())
1193    }
1194
1195    /// How long the last `present` blocked in the swapchain acquire
1196    /// waiting for the compositor to free a frame slot.
1197    #[must_use]
1198    pub fn acquire_wait(&self) -> std::time::Duration {
1199        self.last_acquire_wait
1200    }
1201
1202    /// Resize the wgpu surface, MSAA texture, and viewport projection.
1203    ///
1204    /// `logical_w` and `logical_h` are in logical points (same coordinate
1205    /// space as `BuiltinEditor::size()`). Returns `true` if the surface
1206    /// was actually reconfigured.
1207    #[allow(clippy::cast_precision_loss)]
1208    pub fn resize(&mut self, logical_w: u32, logical_h: u32) -> bool {
1209        // Belt-and-braces: cap each axis at whatever the adapter
1210        // granted us in `from_surface`. The required_limits passed in
1211        // are adapter-native, but a host that ignores `gui_set_size`
1212        // could still hand us a logical*scale request past the cap.
1213        let max_dim = self.device.limits().max_texture_dimension_2d.max(1);
1214        let new_w =
1215            truce_gui_types::to_physical_px(logical_w, f64::from(self.scale)).clamp(1, max_dim);
1216        let new_h =
1217            truce_gui_types::to_physical_px(logical_h, f64::from(self.scale)).clamp(1, max_dim);
1218        if new_w == self.width && new_h == self.height {
1219            return false;
1220        }
1221        self.width = new_w;
1222        self.height = new_h;
1223
1224        if let Some(ref surface) = self.surface
1225            && let Some(ref mut config) = self.surface_config
1226        {
1227            config.width = new_w;
1228            config.height = new_h;
1229            surface.configure(&self.device, config);
1230            self.msaa_texture = Self::create_msaa_texture(&self.device, config);
1231        }
1232        // Pump-managed surface: the pump reconfigures (off the GUI
1233        // thread on Windows); keep the local config + MSAA in step.
1234        #[cfg(not(target_os = "ios"))]
1235        if self.surface.is_none()
1236            && let Some(ref pump) = self.pump
1237            && let Some(ref mut config) = self.surface_config
1238        {
1239            config.width = new_w;
1240            config.height = new_h;
1241            pump.resize(new_w, new_h);
1242            self.msaa_texture = Self::create_msaa_texture(&self.device, config);
1243        }
1244
1245        // Update the orthographic projection matrix.
1246        let matrix = ortho_matrix(new_w as f32, new_h as f32);
1247        self.queue
1248            .write_buffer(&self.viewport_buffer, 0, bytemuck::cast_slice(&matrix));
1249
1250        true
1251    }
1252
1253    // --- Geometry helpers ---
1254
1255    fn color_arr(c: Color) -> [f32; 4] {
1256        [c.r, c.g, c.b, c.a]
1257    }
1258
1259    /// Ensure the current (last) batch targets `image`. If not, close the
1260    /// current batch and open a new one. Call before pushing indices.
1261    fn ensure_batch(&mut self, image: Option<ImageId>) {
1262        let needs_new = self.batches.last().is_none_or(|last| last.image != image);
1263        if needs_new {
1264            self.batches.push(DrawBatch {
1265                index_start: len_u32(self.indices.len()),
1266                image,
1267            });
1268        }
1269    }
1270
1271    fn push_quad(&mut self, v0: Vertex, v1: Vertex, v2: Vertex, v3: Vertex) {
1272        self.ensure_batch(None);
1273        let base = len_u32(self.vertices.len());
1274        self.vertices.extend_from_slice(&[v0, v1, v2, v3]);
1275        self.indices
1276            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1277    }
1278
1279    /// Tessellate a lyon path as a filled shape and append to vertex/index buffers.
1280    fn fill_path(&mut self, path: &Path, color: [f32; 4]) {
1281        self.ensure_batch(None);
1282        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1283        let mut tessellator = FillTessellator::new();
1284        let _ = tessellator.tessellate_path(
1285            path,
1286            &FillOptions::tolerance(0.5),
1287            &mut BuffersBuilder::new(&mut buffers, |vertex: FillVertex| {
1288                let p = vertex.position();
1289                Vertex::solid(p.x, p.y, color)
1290            }),
1291        );
1292        let base = len_u32(self.vertices.len());
1293        self.vertices.extend_from_slice(&buffers.vertices);
1294        self.indices
1295            .extend(buffers.indices.iter().map(|i| i + base));
1296    }
1297
1298    /// Tessellate a lyon path as a stroked shape and append to vertex/index buffers.
1299    fn stroke_path(&mut self, path: &Path, color: [f32; 4], opts: &StrokeOptions) {
1300        self.ensure_batch(None);
1301        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1302        let mut tessellator = StrokeTessellator::new();
1303        let _ = tessellator.tessellate_path(
1304            path,
1305            opts,
1306            &mut BuffersBuilder::new(&mut buffers, |vertex: StrokeVertex| {
1307                let p = vertex.position();
1308                Vertex::solid(p.x, p.y, color)
1309            }),
1310        );
1311        let base = len_u32(self.vertices.len());
1312        self.vertices.extend_from_slice(&buffers.vertices);
1313        self.indices
1314            .extend(buffers.indices.iter().map(|i| i + base));
1315    }
1316
1317    /// Upload pending glyph atlas writes to the GPU.
1318    fn flush_atlas(&mut self) {
1319        for (x, y, w, h, data) in self.glyph_atlas.pending.drain(..) {
1320            if w == 0 || h == 0 {
1321                continue;
1322            }
1323            self.queue.write_texture(
1324                wgpu::TexelCopyTextureInfo {
1325                    texture: &self.atlas_texture,
1326                    mip_level: 0,
1327                    origin: wgpu::Origin3d { x, y, z: 0 },
1328                    aspect: wgpu::TextureAspect::All,
1329                },
1330                &data,
1331                wgpu::TexelCopyBufferLayout {
1332                    offset: 0,
1333                    bytes_per_row: Some(w),
1334                    rows_per_image: Some(h),
1335                },
1336                wgpu::Extent3d {
1337                    width: w,
1338                    height: h,
1339                    depth_or_array_layers: 1,
1340                },
1341            );
1342        }
1343    }
1344}
1345
1346// ---------------------------------------------------------------------------
1347// RenderBackend implementation
1348// ---------------------------------------------------------------------------
1349
1350/// All `RenderBackend` methods accept coordinates in **logical points**.
1351/// The backend multiplies by `self.scale` to get physical pixel positions.
1352/// Font glyphs are rasterized at physical resolution for sharp text.
1353// Rasterizer math uses standard short names (`x`, `y`, `w`, `h`,
1354// `r`, `g`, `b`, `s` = scale, etc.).
1355#[allow(clippy::many_single_char_names)]
1356impl RenderBackend for WgpuBackend {
1357    fn clear(&mut self, color: Color) {
1358        self.clear_color = Some(wgpu::Color {
1359            r: f64::from(color.r),
1360            g: f64::from(color.g),
1361            b: f64::from(color.b),
1362            a: f64::from(color.a),
1363        });
1364        self.vertices.clear();
1365        self.indices.clear();
1366        self.batches.clear();
1367        // If a previous frame hit atlas overflow, do the eviction at the
1368        // frame boundary now - past frames' vertex buffers are gone, so
1369        // dropping the UV cache is safe. Glyphs re-rasterize lazily as
1370        // draw_text walks them this frame.
1371        if self.glyph_atlas.overflow_pending {
1372            self.glyph_atlas.clear();
1373        }
1374    }
1375
1376    fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
1377        let s = self.scale;
1378        let c = Self::color_arr(color);
1379        self.push_quad(
1380            Vertex::solid(x * s, y * s, c),
1381            Vertex::solid((x + w) * s, y * s, c),
1382            Vertex::solid((x + w) * s, (y + h) * s, c),
1383            Vertex::solid(x * s, (y + h) * s, c),
1384        );
1385    }
1386
1387    fn fill_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color) {
1388        let s = self.scale;
1389        let c = Self::color_arr(color);
1390        let mut builder = Path::builder();
1391        builder.add_circle(
1392            point(cx * s, cy * s),
1393            radius * s,
1394            lyon_tessellation::path::Winding::Positive,
1395        );
1396        let path = builder.build();
1397        self.fill_path(&path, c);
1398    }
1399
1400    fn stroke_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color, width: f32) {
1401        let s = self.scale;
1402        let c = Self::color_arr(color);
1403        let mut builder = Path::builder();
1404        builder.add_circle(
1405            point(cx * s, cy * s),
1406            radius * s,
1407            lyon_tessellation::path::Winding::Positive,
1408        );
1409        let path = builder.build();
1410        let opts = StrokeOptions::tolerance(0.5).with_line_width(width * s);
1411        self.stroke_path(&path, c, &opts);
1412    }
1413
1414    #[allow(clippy::cast_precision_loss)]
1415    fn stroke_arc(
1416        &mut self,
1417        cx: f32,
1418        cy: f32,
1419        radius: f32,
1420        start_angle: f32,
1421        end_angle: f32,
1422        color: Color,
1423        width: f32,
1424    ) {
1425        let s = self.scale;
1426        let c = Self::color_arr(color);
1427        let segments = 64u32;
1428        let sweep = end_angle - start_angle;
1429        let step = sweep / segments as f32;
1430
1431        let mut builder = Path::builder();
1432        builder.begin(point(
1433            cx * s + radius * s * start_angle.cos(),
1434            cy * s + radius * s * start_angle.sin(),
1435        ));
1436        for i in 1..=segments {
1437            let angle = start_angle + step * i as f32;
1438            builder.line_to(point(
1439                cx * s + radius * s * angle.cos(),
1440                cy * s + radius * s * angle.sin(),
1441            ));
1442        }
1443        builder.end(false);
1444        let path = builder.build();
1445
1446        let opts = StrokeOptions::tolerance(0.5)
1447            .with_line_width(width * s)
1448            .with_line_cap(lyon_tessellation::LineCap::Round);
1449        self.stroke_path(&path, c, &opts);
1450    }
1451
1452    fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, color: Color, width: f32) {
1453        let s = self.scale;
1454        let c = Self::color_arr(color);
1455        let mut builder = Path::builder();
1456        builder.begin(point(x1 * s, y1 * s));
1457        builder.line_to(point(x2 * s, y2 * s));
1458        builder.end(false);
1459        let path = builder.build();
1460
1461        let opts = StrokeOptions::tolerance(0.5)
1462            .with_line_width(width * s)
1463            .with_line_cap(lyon_tessellation::LineCap::Round);
1464        self.stroke_path(&path, c, &opts);
1465    }
1466
1467    // Glyph cache key uses `(phys_size * 10.0) as u32` quantization,
1468    // matching `ensure_glyph`. Window-bounded coordinates fit in u32.
1469    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1470    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: Color) {
1471        let s = self.scale;
1472        let phys_size = size * s;
1473        let c = Self::color_arr(color);
1474        let ascent = self.font.ascent(phys_size);
1475
1476        let mut cursor_x = x * s;
1477
1478        let chars: Vec<char> = text.chars().collect();
1479        for &ch in &chars {
1480            self.glyph_atlas.ensure_glyph(&self.font, ch, phys_size);
1481        }
1482
1483        // `.get` rather than `[..]` - when the atlas overflows mid-frame,
1484        // `ensure_glyph` skips the insert (see GlyphAtlas::ensure_glyph)
1485        // and we want to drop the missing glyph silently rather than
1486        // panic on lookup. The next frame clears the atlas and these
1487        // glyphs come back.
1488        for &ch in &chars {
1489            let key = (ch, (phys_size * 10.0) as u32);
1490            let Some(g) = self.glyph_atlas.glyphs.get(&key) else {
1491                continue;
1492            };
1493            let (u0, v0, u1, v1, gw, gh, y_off, advance) = (
1494                g.u0, g.v0, g.u1, g.v1, g.width, g.height, g.y_offset, g.advance,
1495            );
1496            // Snap the glyph quad to integer pixel positions on emit.
1497            // Atlas texels and screen pixels are 1:1 (glyphs are
1498            // rasterized at `phys_size`); the shared sampler is
1499            // `FilterMode::Linear`, so a fractional `gx`/`gy` would
1500            // produce per-output-pixel UV interpolation that mixes
1501            // neighbouring atlas texels and smears the glyph. Snapping
1502            // on emit (while `cursor_x` keeps full f32 advance) yields
1503            // crisp glyphs with the kerning error capped at ±0.5px,
1504            // invisible at UI text sizes.
1505            let gx = cursor_x.round();
1506            let gy = (y * s + ascent - y_off - gh).round();
1507
1508            self.push_quad(
1509                Vertex::glyph(gx, gy, c, u0, v0),
1510                Vertex::glyph(gx + gw, gy, c, u1, v0),
1511                Vertex::glyph(gx + gw, gy + gh, c, u1, v1),
1512                Vertex::glyph(gx, gy + gh, c, u0, v1),
1513            );
1514
1515            cursor_x += advance;
1516        }
1517    }
1518
1519    fn text_width(&self, text: &str, size: f32) -> f32 {
1520        let phys_size = size * self.scale;
1521        // Sum advance widths via the local rasterizer. Doing the math
1522        // inline keeps truce-gpu independent of truce-gui.
1523        let phys: f32 = text
1524            .chars()
1525            .map(|ch| self.font.advance(ch, phys_size))
1526            .sum();
1527        phys / self.scale
1528    }
1529
1530    fn register_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
1531        let expected = (width as usize) * (height as usize) * 4;
1532        if width == 0 || height == 0 || rgba.len() < expected {
1533            return ImageId::INVALID;
1534        }
1535
1536        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1537            label: Some("image"),
1538            size: wgpu::Extent3d {
1539                width,
1540                height,
1541                depth_or_array_layers: 1,
1542            },
1543            mip_level_count: 1,
1544            sample_count: 1,
1545            dimension: wgpu::TextureDimension::D2,
1546            format: wgpu::TextureFormat::Rgba8Unorm,
1547            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1548            view_formats: &[],
1549        });
1550
1551        self.queue.write_texture(
1552            wgpu::TexelCopyTextureInfo {
1553                texture: &texture,
1554                mip_level: 0,
1555                origin: wgpu::Origin3d::ZERO,
1556                aspect: wgpu::TextureAspect::All,
1557            },
1558            &rgba[..expected],
1559            wgpu::TexelCopyBufferLayout {
1560                offset: 0,
1561                bytes_per_row: Some(width * 4),
1562                rows_per_image: Some(height),
1563            },
1564            wgpu::Extent3d {
1565                width,
1566                height,
1567                depth_or_array_layers: 1,
1568            },
1569        );
1570
1571        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1572        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1573            label: Some("image-bg"),
1574            layout: &self.tex_bind_group_layout,
1575            entries: &[
1576                wgpu::BindGroupEntry {
1577                    binding: 0,
1578                    resource: wgpu::BindingResource::TextureView(&view),
1579                },
1580                wgpu::BindGroupEntry {
1581                    binding: 1,
1582                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1583                },
1584            ],
1585        });
1586
1587        let entry = ImageEntry {
1588            _texture: texture,
1589            bind_group,
1590        };
1591
1592        if let Some((idx, slot)) = self
1593            .images
1594            .iter_mut()
1595            .enumerate()
1596            .find(|(_, s)| s.is_none())
1597        {
1598            *slot = Some(entry);
1599            return ImageId(len_u32(idx));
1600        }
1601        let id = len_u32(self.images.len());
1602        self.images.push(Some(entry));
1603        ImageId(id)
1604    }
1605
1606    fn unregister_image(&mut self, id: ImageId) {
1607        if let Some(slot) = self.images.get_mut(id.0 as usize) {
1608            *slot = None;
1609        }
1610    }
1611
1612    fn draw_image(&mut self, id: ImageId, x: f32, y: f32, w: f32, h: f32) {
1613        if self
1614            .images
1615            .get(id.0 as usize)
1616            .and_then(|s| s.as_ref())
1617            .is_none()
1618        {
1619            return;
1620        }
1621        self.ensure_batch(Some(id));
1622
1623        let s = self.scale;
1624        let c = [1.0, 1.0, 1.0, 1.0];
1625        let base = len_u32(self.vertices.len());
1626        self.vertices.extend_from_slice(&[
1627            Vertex::image(x * s, y * s, c, 0.0, 0.0),
1628            Vertex::image((x + w) * s, y * s, c, 1.0, 0.0),
1629            Vertex::image((x + w) * s, (y + h) * s, c, 1.0, 1.0),
1630            Vertex::image(x * s, (y + h) * s, c, 0.0, 1.0),
1631        ]);
1632        self.indices
1633            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1634    }
1635
1636    fn present(&mut self) {
1637        // Pump-managed surface: paint into the pump's pre-acquired
1638        // frame and hand it back for presentation. The frame is taken
1639        // BEFORE any queue work (atlas upload): during resize churn no
1640        // frame is available (the pump is busy reconfiguring), and
1641        // skipping keeps GUI-thread submissions from contending on
1642        // wgpu's internal locks with the pump's in-flight configure.
1643        // A skip loses nothing - the editor re-records its geometry
1644        // every paint.
1645        #[cfg(not(target_os = "ios"))]
1646        if let Some(pump) = self.pump.clone() {
1647            let frame = pump.try_take_frame();
1648            self.last_acquire_wait = pump.last_acquire_wait();
1649            let Some(frame) = frame else {
1650                return;
1651            };
1652            // A resize raced the acquire: this frame is at the old
1653            // extent. Discard it (the pump reconfigures + reacquires).
1654            let expected = self.surface_config.as_ref().map(|c| (c.width, c.height));
1655            if expected != Some((frame.texture.width(), frame.texture.height())) {
1656                pump.discard(frame);
1657                return;
1658            }
1659            // Upload pending glyph atlas writes now that the paint is
1660            // definitely happening.
1661            self.flush_atlas();
1662            let frame_view = frame
1663                .texture
1664                .create_view(&wgpu::TextureViewDescriptor::default());
1665            if self.vertices.is_empty() {
1666                self.clear_only_pass(&frame_view);
1667            } else {
1668                self.render_pass(&frame_view);
1669            }
1670            pump.present(frame);
1671            return;
1672        }
1673
1674        // Upload any pending glyph atlas writes (before borrowing surface)
1675        self.flush_atlas();
1676
1677        let Some(surface) = &self.surface else {
1678            return; // headless - no surface to present to
1679        };
1680
1681        // Acquire a swapchain frame, recovering from a stale surface.
1682        // After a window resize on X11/Vulkan the surface reports
1683        // `Outdated` and stays that way until it is reconfigured - even
1684        // reconfiguring to the same size clears the flag, so a plain
1685        // skip-the-frame would freeze the editor on its pre-resize image
1686        // with the desktop showing through the newly exposed area. On
1687        // `Outdated` / `Lost` / `Validation` we reconfigure and retry;
1688        // `Timeout` / `Occluded` are transient, so we skip this frame.
1689        let acquire_start = std::time::Instant::now();
1690        let mut acquired = None;
1691        let mut transient_skip = false;
1692        for _ in 0..2 {
1693            match surface.get_current_texture() {
1694                wgpu::CurrentSurfaceTexture::Success(frame)
1695                | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
1696                    acquired = Some(frame);
1697                    break;
1698                }
1699                wgpu::CurrentSurfaceTexture::Outdated
1700                | wgpu::CurrentSurfaceTexture::Lost
1701                | wgpu::CurrentSurfaceTexture::Validation => {
1702                    if let Some(config) = &self.surface_config {
1703                        surface.configure(&self.device, config);
1704                    }
1705                }
1706                wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
1707                    transient_skip = true;
1708                    break;
1709                }
1710            }
1711        }
1712        self.last_acquire_wait = acquire_start.elapsed();
1713        if transient_skip {
1714            return;
1715        }
1716        let Some(frame) = acquired else {
1717            return;
1718        };
1719        let frame_view = frame
1720            .texture
1721            .create_view(&wgpu::TextureViewDescriptor::default());
1722
1723        if self.vertices.is_empty() {
1724            // No draws this frame, but the surface is double/triple-buffered
1725            // - without a render pass the swap chain shows whatever was
1726            // there before (often the second-prior frame, producing a
1727            // visible flicker on idle). Issue a clear-only pass so the
1728            // surface ends up at `clear_color`.
1729            self.clear_only_pass(&frame_view);
1730            frame.present();
1731            return;
1732        }
1733
1734        self.render_pass(&frame_view);
1735        frame.present();
1736    }
1737}
1738
1739impl WgpuBackend {
1740    /// Issue a render pass that only clears the surface. Used by
1741    /// `present()` when there is no geometry - without it the swap chain
1742    /// would show stale buffer contents. Always clears (`Load` would
1743    /// surface prior-frame garbage), falling back to
1744    /// `present_clear_default` when no `clear()` was requested.
1745    fn clear_only_pass(&mut self, resolve_target: &wgpu::TextureView) {
1746        let clear_color = self.clear_color.unwrap_or(self.present_clear_default);
1747        let mut encoder = self
1748            .device
1749            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1750                label: Some("clear-only"),
1751            });
1752        {
1753            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1754                label: Some("clear-only"),
1755                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1756                    view: &self.msaa_texture,
1757                    resolve_target: Some(resolve_target),
1758                    ops: wgpu::Operations {
1759                        load: wgpu::LoadOp::Clear(clear_color),
1760                        store: wgpu::StoreOp::Discard,
1761                    },
1762                    depth_slice: None,
1763                })],
1764                depth_stencil_attachment: None,
1765                timestamp_writes: None,
1766                occlusion_query_set: None,
1767                multiview_mask: None,
1768            });
1769        }
1770        self.queue.submit(std::iter::once(encoder.finish()));
1771    }
1772
1773    /// Render accumulated geometry to a texture view (shared by present + headless).
1774    fn render_pass(&mut self, resolve_target: &wgpu::TextureView) {
1775        let clear_color = self.clear_color.unwrap_or(self.present_clear_default);
1776        let vertex_buffer = self
1777            .device
1778            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1779                label: Some("vertices"),
1780                contents: bytemuck::cast_slice(&self.vertices),
1781                usage: wgpu::BufferUsages::VERTEX,
1782            });
1783
1784        let index_buffer = self
1785            .device
1786            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1787                label: Some("indices"),
1788                contents: bytemuck::cast_slice(&self.indices),
1789                usage: wgpu::BufferUsages::INDEX,
1790            });
1791
1792        let mut encoder = self
1793            .device
1794            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1795                label: Some("frame"),
1796            });
1797
1798        {
1799            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1800                label: Some("main"),
1801                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1802                    view: &self.msaa_texture,
1803                    resolve_target: Some(resolve_target),
1804                    ops: wgpu::Operations {
1805                        load: wgpu::LoadOp::Clear(clear_color),
1806                        store: wgpu::StoreOp::Discard,
1807                    },
1808                    depth_slice: None,
1809                })],
1810                depth_stencil_attachment: None,
1811                timestamp_writes: None,
1812                occlusion_query_set: None,
1813                multiview_mask: None,
1814            });
1815
1816            pass.set_pipeline(&self.pipeline);
1817            pass.set_bind_group(0, &self.viewport_bind_group, &[]);
1818            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1819            pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1820
1821            let total_indices = len_u32(self.indices.len());
1822            if self.batches.is_empty() {
1823                // Backwards-compatible path: no batching recorded (e.g. a
1824                // caller that bypassed clear()). Draw everything with the
1825                // atlas bind group.
1826                pass.set_bind_group(1, &self.atlas_bind_group, &[]);
1827                pass.draw_indexed(0..total_indices, 0, 0..1);
1828            } else {
1829                for i in 0..self.batches.len() {
1830                    let b = self.batches[i];
1831                    let end = self
1832                        .batches
1833                        .get(i + 1)
1834                        .map_or(total_indices, |n| n.index_start);
1835                    if end <= b.index_start {
1836                        continue;
1837                    }
1838                    let bg = match b.image {
1839                        None => &self.atlas_bind_group,
1840                        Some(img_id) => {
1841                            match self.images.get(img_id.0 as usize).and_then(|s| s.as_ref()) {
1842                                Some(entry) => &entry.bind_group,
1843                                // Image was unregistered mid-frame; skip draw.
1844                                None => continue,
1845                            }
1846                        }
1847                    };
1848                    pass.set_bind_group(1, bg, &[]);
1849                    pass.draw_indexed(b.index_start..end, 0, 0..1);
1850                }
1851            }
1852        }
1853
1854        self.queue.submit(std::iter::once(encoder.finish()));
1855    }
1856
1857    /// Create a headless GPU backend (no window or surface).
1858    /// Used for snapshot testing.
1859    ///
1860    /// # Panics
1861    ///
1862    /// Panics if the embedded font fails to parse (bundled-asset
1863    /// bug, never user input).
1864    // Surface dimensions in pixels stay below 2^23, well within f32.
1865    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
1866    #[must_use]
1867    pub fn headless(width: u32, height: u32, scale: f32) -> Option<Self> {
1868        let phys_w = truce_gui_types::to_physical_px(width, f64::from(scale));
1869        let phys_h = truce_gui_types::to_physical_px(height, f64::from(scale));
1870
1871        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
1872        desc.backends = wgpu::Backends::PRIMARY;
1873        let instance = wgpu::Instance::new(desc);
1874
1875        // Headless: there is no `wgpu::Surface` to constrain the adapter
1876        // pick against, so `compatible_surface` is `None`. On a multi-GPU
1877        // host (e.g. discrete + integrated, or NVIDIA Optimus) wgpu may
1878        // pick a different physical adapter than the live render path's
1879        // `compatible_surface: Some(&surface)`, which can produce subtle
1880        // rasterization differences (driver-specific shader compile, MSAA
1881        // resolve, sRGB rounding). Bake screenshot baselines on the host
1882        // you gate from - this is the same constraint already documented
1883        // in `cargo truce screenshot --check`.
1884        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1885            power_preference: wgpu::PowerPreference::HighPerformance,
1886            compatible_surface: None,
1887            force_fallback_adapter: false,
1888        }))
1889        .ok()?;
1890
1891        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
1892            label: Some("truce-gpu-headless"),
1893            required_features: wgpu::Features::empty(),
1894            required_limits: adapter.limits(),
1895            experimental_features: wgpu::ExperimentalFeatures::default(),
1896            memory_hints: wgpu::MemoryHints::Performance,
1897            trace: wgpu::Trace::Off,
1898        }))
1899        .ok()?;
1900        let device = Arc::new(device);
1901        let queue = Arc::new(queue);
1902
1903        // Use non-sRGB to match the windowed path (which picks !is_srgb())
1904        let texture_format = wgpu::TextureFormat::Rgba8Unorm;
1905
1906        // MSAA texture
1907        let msaa_texture = device.create_texture(&wgpu::TextureDescriptor {
1908            label: Some("msaa"),
1909            size: wgpu::Extent3d {
1910                width: phys_w,
1911                height: phys_h,
1912                depth_or_array_layers: 1,
1913            },
1914            mip_level_count: 1,
1915            sample_count: 4,
1916            dimension: wgpu::TextureDimension::D2,
1917            format: texture_format,
1918            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1919            view_formats: &[],
1920        });
1921        let msaa_view = msaa_texture.create_view(&wgpu::TextureViewDescriptor::default());
1922
1923        // Shader
1924        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1925            label: Some("truce-gpu-shader"),
1926            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
1927        });
1928
1929        // Viewport
1930        let matrix = ortho_matrix(phys_w as f32, phys_h as f32);
1931        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1932            label: Some("viewport"),
1933            contents: bytemuck::cast_slice(&matrix),
1934            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1935        });
1936
1937        let viewport_bind_group_layout =
1938            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1939                label: Some("viewport-layout"),
1940                entries: &[wgpu::BindGroupLayoutEntry {
1941                    binding: 0,
1942                    visibility: wgpu::ShaderStages::VERTEX,
1943                    ty: wgpu::BindingType::Buffer {
1944                        ty: wgpu::BufferBindingType::Uniform,
1945                        has_dynamic_offset: false,
1946                        min_binding_size: None,
1947                    },
1948                    count: None,
1949                }],
1950            });
1951
1952        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1953            label: Some("viewport-bg"),
1954            layout: &viewport_bind_group_layout,
1955            entries: &[wgpu::BindGroupEntry {
1956                binding: 0,
1957                resource: viewport_buffer.as_entire_binding(),
1958            }],
1959        });
1960
1961        // Atlas
1962        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
1963            label: Some("glyph-atlas"),
1964            size: wgpu::Extent3d {
1965                width: ATLAS_SIZE,
1966                height: ATLAS_SIZE,
1967                depth_or_array_layers: 1,
1968            },
1969            mip_level_count: 1,
1970            sample_count: 1,
1971            dimension: wgpu::TextureDimension::D2,
1972            format: wgpu::TextureFormat::R8Unorm,
1973            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1974            view_formats: &[],
1975        });
1976        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
1977        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1978            mag_filter: wgpu::FilterMode::Linear,
1979            min_filter: wgpu::FilterMode::Linear,
1980            ..Default::default()
1981        });
1982        let tex_bind_group_layout =
1983            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1984                label: Some("tex-layout"),
1985                entries: &[
1986                    wgpu::BindGroupLayoutEntry {
1987                        binding: 0,
1988                        visibility: wgpu::ShaderStages::FRAGMENT,
1989                        ty: wgpu::BindingType::Texture {
1990                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
1991                            view_dimension: wgpu::TextureViewDimension::D2,
1992                            multisampled: false,
1993                        },
1994                        count: None,
1995                    },
1996                    wgpu::BindGroupLayoutEntry {
1997                        binding: 1,
1998                        visibility: wgpu::ShaderStages::FRAGMENT,
1999                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2000                        count: None,
2001                    },
2002                ],
2003            });
2004        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2005            label: Some("atlas-bg"),
2006            layout: &tex_bind_group_layout,
2007            entries: &[
2008                wgpu::BindGroupEntry {
2009                    binding: 0,
2010                    resource: wgpu::BindingResource::TextureView(&atlas_view),
2011                },
2012                wgpu::BindGroupEntry {
2013                    binding: 1,
2014                    resource: wgpu::BindingResource::Sampler(&sampler),
2015                },
2016            ],
2017        });
2018
2019        // Pipeline
2020        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2021            label: Some("truce-gpu-pipeline-layout"),
2022            bind_group_layouts: &[
2023                Some(&viewport_bind_group_layout),
2024                Some(&tex_bind_group_layout),
2025            ],
2026            immediate_size: 0,
2027        });
2028
2029        let vertex_layout = wgpu::VertexBufferLayout {
2030            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
2031            step_mode: wgpu::VertexStepMode::Vertex,
2032            attributes: &[
2033                wgpu::VertexAttribute {
2034                    offset: 0,
2035                    shader_location: 0,
2036                    format: wgpu::VertexFormat::Float32x2,
2037                },
2038                wgpu::VertexAttribute {
2039                    offset: 8,
2040                    shader_location: 1,
2041                    format: wgpu::VertexFormat::Float32x4,
2042                },
2043                wgpu::VertexAttribute {
2044                    offset: 24,
2045                    shader_location: 2,
2046                    format: wgpu::VertexFormat::Float32x2,
2047                },
2048                wgpu::VertexAttribute {
2049                    offset: 32,
2050                    shader_location: 3,
2051                    format: wgpu::VertexFormat::Float32,
2052                },
2053            ],
2054        };
2055
2056        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2057            label: Some("truce-gpu-pipeline"),
2058            layout: Some(&pipeline_layout),
2059            vertex: wgpu::VertexState {
2060                module: &shader,
2061                entry_point: Some("vs_main"),
2062                buffers: &[vertex_layout],
2063                compilation_options: wgpu::PipelineCompilationOptions::default(),
2064            },
2065            fragment: Some(wgpu::FragmentState {
2066                module: &shader,
2067                entry_point: Some("fs_main"),
2068                targets: &[Some(wgpu::ColorTargetState {
2069                    format: texture_format,
2070                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
2071                    write_mask: wgpu::ColorWrites::ALL,
2072                })],
2073                compilation_options: wgpu::PipelineCompilationOptions::default(),
2074            }),
2075            primitive: wgpu::PrimitiveState {
2076                topology: wgpu::PrimitiveTopology::TriangleList,
2077                ..Default::default()
2078            },
2079            depth_stencil: None,
2080            multisample: wgpu::MultisampleState {
2081                count: 4,
2082                mask: !0,
2083                alpha_to_coverage_enabled: false,
2084            },
2085            multiview_mask: None,
2086            cache: None,
2087        });
2088
2089        let font = truce_font::raster::FontRaster::new();
2090
2091        Some(Self {
2092            device,
2093            queue,
2094            surface: None,
2095            surface_config: None,
2096            #[cfg(not(target_os = "ios"))]
2097            pump: None,
2098            pipeline,
2099            target_format: texture_format,
2100            msaa_texture: msaa_view,
2101            msaa_width: phys_w,
2102            msaa_height: phys_h,
2103            vertices: Vec::with_capacity(4096),
2104            indices: Vec::with_capacity(8192),
2105            batches: Vec::new(),
2106            glyph_atlas: GlyphAtlas::new(),
2107            font,
2108            atlas_texture,
2109            atlas_bind_group,
2110            tex_bind_group_layout,
2111            sampler,
2112            images: Vec::new(),
2113            viewport_buffer,
2114            viewport_bind_group,
2115            clear_color: None,
2116            present_clear_default: wgpu::Color::BLACK,
2117            width: phys_w,
2118            height: phys_h,
2119            scale,
2120            last_acquire_wait: std::time::Duration::ZERO,
2121        })
2122    }
2123
2124    /// Render to an offscreen texture and read back RGBA pixels.
2125    /// Only works for headless backends (no surface).
2126    ///
2127    /// # Panics
2128    ///
2129    /// Panics if `wgpu::Buffer::map_async` reports failure when reading
2130    /// back the GPU readback buffer - that indicates an adapter / driver
2131    /// fault rather than a recoverable runtime condition, so the
2132    /// snapshot path bubbles it up rather than papering over it.
2133    pub fn read_pixels(&mut self) -> Vec<u8> {
2134        self.flush_atlas();
2135
2136        let w = self.width;
2137        let h = self.height;
2138        let format = wgpu::TextureFormat::Rgba8Unorm;
2139
2140        // Offscreen resolve target
2141        let target_texture = self.device.create_texture(&wgpu::TextureDescriptor {
2142            label: Some("offscreen"),
2143            size: wgpu::Extent3d {
2144                width: w,
2145                height: h,
2146                depth_or_array_layers: 1,
2147            },
2148            mip_level_count: 1,
2149            sample_count: 1,
2150            dimension: wgpu::TextureDimension::D2,
2151            format,
2152            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2153            view_formats: &[],
2154        });
2155        let target_view = target_texture.create_view(&wgpu::TextureViewDescriptor::default());
2156
2157        // Render
2158        if !self.vertices.is_empty() {
2159            self.render_pass(&target_view);
2160        }
2161
2162        // Readback
2163        let bytes_per_row = (w * 4 + 255) & !255; // 256-byte aligned
2164        let readback_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2165            label: Some("readback"),
2166            size: u64::from(bytes_per_row * h),
2167            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2168            mapped_at_creation: false,
2169        });
2170
2171        let mut encoder = self
2172            .device
2173            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2174                label: Some("readback"),
2175            });
2176        encoder.copy_texture_to_buffer(
2177            wgpu::TexelCopyTextureInfo {
2178                texture: &target_texture,
2179                mip_level: 0,
2180                origin: wgpu::Origin3d::ZERO,
2181                aspect: wgpu::TextureAspect::All,
2182            },
2183            wgpu::TexelCopyBufferInfo {
2184                buffer: &readback_buf,
2185                layout: wgpu::TexelCopyBufferLayout {
2186                    offset: 0,
2187                    bytes_per_row: Some(bytes_per_row),
2188                    rows_per_image: None,
2189                },
2190            },
2191            wgpu::Extent3d {
2192                width: w,
2193                height: h,
2194                depth_or_array_layers: 1,
2195            },
2196        );
2197        self.queue.submit(std::iter::once(encoder.finish()));
2198
2199        // Map and copy
2200        let buf_slice = readback_buf.slice(..);
2201        let (tx, rx) = std::sync::mpsc::channel();
2202        buf_slice.map_async(wgpu::MapMode::Read, move |result| {
2203            tx.send(result).unwrap();
2204        });
2205        let _ = self.device.poll(wgpu::PollType::Wait {
2206            submission_index: None,
2207            timeout: None,
2208        });
2209        rx.recv().unwrap().expect("buffer map failed");
2210
2211        let mapped = buf_slice.get_mapped_range();
2212        let mut pixels = Vec::with_capacity((w * h * 4) as usize);
2213        for row in 0..h {
2214            let start = (row * bytes_per_row) as usize;
2215            let end = start + (w * 4) as usize;
2216            pixels.extend_from_slice(&mapped[start..end]);
2217        }
2218        drop(mapped);
2219        readback_buf.unmap();
2220
2221        // Shader writes premultiplied alpha (BlendState::PREMULTIPLIED_ALPHA_BLENDING),
2222        // but downstream consumers - `truce-test`'s reference-PNG comparison
2223        // and `cargo truce screenshot` output - assume straight RGBA, the
2224        // same convention `truce-slint::screenshot::render_with_state` uses.
2225        // Un-premultiply here so the GPU readback matches the headless
2226        // contract instead of leaking the GPU's internal format.
2227        for px in pixels.chunks_exact_mut(4) {
2228            let a = px[3];
2229            if a == 0 || a == 255 {
2230                continue;
2231            }
2232            let a16 = u16::from(a);
2233            // Round to nearest: (c * 255 + a/2) / a.
2234            px[0] = ((u16::from(px[0]) * 255 + a16 / 2) / a16).min(255) as u8;
2235            px[1] = ((u16::from(px[1]) * 255 + a16 / 2) / a16).min(255) as u8;
2236            px[2] = ((u16::from(px[2]) * 255 + a16 / 2) / a16).min(255) as u8;
2237        }
2238
2239        pixels
2240    }
2241}
2242
2243// ---------------------------------------------------------------------------
2244// Tests
2245// ---------------------------------------------------------------------------
2246
2247#[cfg(test)]
2248mod tests {
2249    use super::*;
2250
2251    #[test]
2252    fn vertex_size() {
2253        // 2 (pos) + 4 (color) + 2 (uv) + 1 (tex_mix) + 1 (pad) = 10 floats = 40 bytes
2254        let size = std::mem::size_of::<Vertex>();
2255        assert!(size > 0, "Vertex should have non-zero size: {size}");
2256    }
2257
2258    // Both ortho-matrix tests check that the helper maps top-left
2259    // screen-space origin (0, 0) to wgpu's top-left clip-space corner
2260    // (-1, +1) and bottom-right screen (w, h) to clip (+1, -1). The Y
2261    // flip is the `-2.0 / h` term in `ortho_matrix` - without it,
2262    // increasing screen-y would move the vertex *up* in clip space.
2263    #[test]
2264    fn ortho_matrix_maps_origin() {
2265        let m = ortho_matrix(800.0, 600.0);
2266        let x = m[0][0] * 0.0 + m[3][0];
2267        let y = m[1][1] * 0.0 + m[3][1];
2268        assert!((x - (-1.0)).abs() < 1e-6);
2269        assert!((y - 1.0).abs() < 1e-6);
2270    }
2271
2272    #[test]
2273    fn ortho_matrix_maps_bottom_right() {
2274        let m = ortho_matrix(800.0, 600.0);
2275        let x = m[0][0] * 800.0 + m[3][0];
2276        let y = m[1][1] * 600.0 + m[3][1];
2277        assert!((x - 1.0).abs() < 1e-6);
2278        assert!((y - (-1.0)).abs() < 1e-6);
2279    }
2280
2281    #[test]
2282    fn glyph_atlas_shelf_packing() {
2283        let font = truce_font::raster::FontRaster::new();
2284        let mut atlas = GlyphAtlas::new();
2285
2286        // Pack a few glyphs
2287        atlas.ensure_glyph(&font, 'A', 14.0);
2288        atlas.ensure_glyph(&font, 'B', 14.0);
2289        atlas.ensure_glyph(&font, 'C', 14.0);
2290
2291        assert_eq!(atlas.glyphs.len(), 3);
2292        assert!(!atlas.pending.is_empty());
2293
2294        // Same glyph at same size should not create a new entry
2295        atlas.ensure_glyph(&font, 'A', 14.0);
2296        assert_eq!(atlas.glyphs.len(), 3);
2297    }
2298
2299    #[test]
2300    fn lyon_fill_circle_produces_triangles() {
2301        let mut builder = Path::builder();
2302        builder.add_circle(
2303            point(50.0, 50.0),
2304            10.0,
2305            lyon_tessellation::path::Winding::Positive,
2306        );
2307        let path = builder.build();
2308        let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
2309        let mut tess = FillTessellator::new();
2310        tess.tessellate_path(
2311            &path,
2312            &FillOptions::tolerance(0.5),
2313            &mut BuffersBuilder::new(&mut buffers, |v: FillVertex| {
2314                let p = v.position();
2315                [p.x, p.y]
2316            }),
2317        )
2318        .unwrap();
2319        assert!(buffers.vertices.len() >= 3);
2320        assert!(buffers.indices.len() >= 3);
2321    }
2322
2323    /// End-to-end smoke test for the standalone `new` / `begin_frame` /
2324    /// `finish` path: build a backend against a caller-owned device,
2325    /// record some primitives + text, render into an offscreen texture,
2326    /// and verify we wrote non-background pixels.
2327    #[test]
2328    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
2329    fn standalone_pipeline_renders() {
2330        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
2331        desc.backends = wgpu::Backends::PRIMARY;
2332        let instance = wgpu::Instance::new(desc);
2333        let Ok(adapter) =
2334            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
2335                power_preference: wgpu::PowerPreference::HighPerformance,
2336                compatible_surface: None,
2337                force_fallback_adapter: false,
2338            }))
2339        else {
2340            return; // no GPU in this environment
2341        };
2342        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
2343            label: Some("standalone-test"),
2344            required_features: wgpu::Features::empty(),
2345            required_limits: adapter.limits(),
2346            experimental_features: wgpu::ExperimentalFeatures::default(),
2347            memory_hints: wgpu::MemoryHints::Performance,
2348            trace: wgpu::Trace::Off,
2349        }))
2350        .expect("request_device");
2351        let device = Arc::new(device);
2352        let queue = Arc::new(queue);
2353
2354        let w = 64u32;
2355        let h = 48u32;
2356        let format = wgpu::TextureFormat::Rgba8Unorm;
2357        let mut backend =
2358            WgpuBackend::new(Arc::clone(&device), Arc::clone(&queue), format, w, h, 1.0)
2359                .expect("backend new");
2360
2361        // Pre-fill the offscreen target with red so we can tell apart
2362        // "finish drew something" from "finish cleared to background".
2363        let target = device.create_texture(&wgpu::TextureDescriptor {
2364            label: Some("standalone-target"),
2365            size: wgpu::Extent3d {
2366                width: w,
2367                height: h,
2368                depth_or_array_layers: 1,
2369            },
2370            mip_level_count: 1,
2371            sample_count: 1,
2372            dimension: wgpu::TextureDimension::D2,
2373            format,
2374            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2375            view_formats: &[],
2376        });
2377        let view = target.create_view(&wgpu::TextureViewDescriptor::default());
2378
2379        backend.begin_frame(w, h);
2380        backend.clear(Color::rgb(0.0, 0.0, 0.0));
2381        backend.fill_rect(8.0, 8.0, 16.0, 16.0, Color::rgb(0.0, 1.0, 0.0));
2382        backend.draw_text("x", 20.0, 20.0, 14.0, Color::rgb(1.0, 1.0, 1.0));
2383
2384        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2385            label: Some("standalone-enc"),
2386        });
2387        backend.finish(&mut encoder, &view);
2388
2389        // Copy target to a readback buffer and inspect.
2390        let bytes_per_row = (w * 4 + 255) & !255;
2391        let readback = device.create_buffer(&wgpu::BufferDescriptor {
2392            label: Some("readback"),
2393            size: u64::from(bytes_per_row * h),
2394            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2395            mapped_at_creation: false,
2396        });
2397        encoder.copy_texture_to_buffer(
2398            wgpu::TexelCopyTextureInfo {
2399                texture: &target,
2400                mip_level: 0,
2401                origin: wgpu::Origin3d::ZERO,
2402                aspect: wgpu::TextureAspect::All,
2403            },
2404            wgpu::TexelCopyBufferInfo {
2405                buffer: &readback,
2406                layout: wgpu::TexelCopyBufferLayout {
2407                    offset: 0,
2408                    bytes_per_row: Some(bytes_per_row),
2409                    rows_per_image: None,
2410                },
2411            },
2412            wgpu::Extent3d {
2413                width: w,
2414                height: h,
2415                depth_or_array_layers: 1,
2416            },
2417        );
2418        queue.submit(std::iter::once(encoder.finish()));
2419
2420        let slice = readback.slice(..);
2421        let (tx, rx) = std::sync::mpsc::channel();
2422        slice.map_async(wgpu::MapMode::Read, move |r| {
2423            tx.send(r).unwrap();
2424        });
2425        let _ = device.poll(wgpu::PollType::Wait {
2426            submission_index: None,
2427            timeout: None,
2428        });
2429        rx.recv().unwrap().unwrap();
2430        let mapped = slice.get_mapped_range();
2431
2432        // Probe the green rect center (16, 16) - should be ~green.
2433        let row_off = 16usize * bytes_per_row as usize;
2434        let px_off = row_off + 16 * 4;
2435        let r = mapped[px_off];
2436        let g = mapped[px_off + 1];
2437        let b = mapped[px_off + 2];
2438        assert!(g > 200, "green rect not rendered: got rgb=({r},{g},{b})");
2439        assert!(
2440            r < 50 && b < 50,
2441            "green rect leaked other channels: rgb=({r},{g},{b})"
2442        );
2443    }
2444}