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