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 instance = wgpu::Instance::new(crate::platform::editor_instance_descriptor());
667
668            let surface = crate::platform::create_wgpu_surface(&instance, window)?;
669            Self::from_surface(&instance, surface, logical_w, logical_h, scale)
670        }
671    }
672
673    /// Build a standalone `WgpuBackend` that records into encoders
674    /// supplied per-frame by the caller.
675    ///
676    /// Unlike [`Self::from_surface`] / `from_metal_layer` / [`Self::from_window`],
677    /// this constructor does **not** own a `wgpu::Surface` or manage
678    /// frame acquisition. The caller is expected to have its own render
679    /// loop, allocate command encoders, and present - this backend only
680    /// supplies the 2D widget pipeline, glyph atlas, and lyon-tessellated
681    /// primitive recording.
682    ///
683    /// Usage:
684    ///
685    /// ```ignore
686    /// let mut backend = WgpuBackend::new(
687    ///     device.clone(), queue.clone(),
688    ///     target_format, max_w, max_h,
689    /// ).expect("backend init");
690    ///
691    /// // per-frame, after the caller has drawn its own content into `view`:
692    /// backend.begin_frame(w, h);
693    /// truce_gui::widgets::draw(&mut backend, &layout, &theme, &snap, &mut state);
694    /// backend.finish(&mut encoder, &view);
695    /// // caller submits encoder + presents.
696    /// ```
697    ///
698    /// `max_logical_w` / `max_logical_h` are in logical points; `scale`
699    /// is the display scale factor (2.0 on Retina, 1.0 otherwise). The
700    /// MSAA texture is seeded at `logical × scale` physical pixels; if
701    /// a subsequent `begin_frame(logical_w, logical_h)` exceeds the
702    /// seed, the MSAA texture is reallocated transparently.
703    ///
704    /// Matches the coordinate contract of [`Self::from_surface`] /
705    /// [`Self::from_window`]: draw calls and event coordinates are logical
706    /// points; the backend multiplies by `scale` internally when
707    /// rasterizing.
708    ///
709    /// # Panics
710    ///
711    /// Panics if the embedded font fails to parse (bundled-asset
712    /// bug, never user input).
713    // Surface dimensions in pixels stay below 2^23, well within f32.
714    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
715    #[must_use]
716    pub fn new(
717        device: Arc<wgpu::Device>,
718        queue: Arc<wgpu::Queue>,
719        target_format: wgpu::TextureFormat,
720        max_logical_w: u32,
721        max_logical_h: u32,
722        scale: f32,
723    ) -> Option<Self> {
724        let scale = scale.max(0.0);
725        let width = truce_gui_types::to_physical_px(max_logical_w, f64::from(scale));
726        let height = truce_gui_types::to_physical_px(max_logical_h, f64::from(scale));
727
728        // Shader
729        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
730            label: Some("truce-gpu-shader"),
731            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
732        });
733
734        // Viewport uniform
735        let matrix = ortho_matrix(width as f32, height as f32);
736        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
737            label: Some("viewport"),
738            contents: bytemuck::cast_slice(&matrix),
739            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
740        });
741
742        let viewport_bind_group_layout =
743            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
744                label: Some("viewport-layout"),
745                entries: &[wgpu::BindGroupLayoutEntry {
746                    binding: 0,
747                    visibility: wgpu::ShaderStages::VERTEX,
748                    ty: wgpu::BindingType::Buffer {
749                        ty: wgpu::BufferBindingType::Uniform,
750                        has_dynamic_offset: false,
751                        min_binding_size: None,
752                    },
753                    count: None,
754                }],
755            });
756
757        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
758            label: Some("viewport-bg"),
759            layout: &viewport_bind_group_layout,
760            entries: &[wgpu::BindGroupEntry {
761                binding: 0,
762                resource: viewport_buffer.as_entire_binding(),
763            }],
764        });
765
766        // Glyph atlas
767        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
768            label: Some("glyph-atlas"),
769            size: wgpu::Extent3d {
770                width: ATLAS_SIZE,
771                height: ATLAS_SIZE,
772                depth_or_array_layers: 1,
773            },
774            mip_level_count: 1,
775            sample_count: 1,
776            dimension: wgpu::TextureDimension::D2,
777            format: wgpu::TextureFormat::R8Unorm,
778            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
779            view_formats: &[],
780        });
781        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
782        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
783            mag_filter: wgpu::FilterMode::Linear,
784            min_filter: wgpu::FilterMode::Linear,
785            ..Default::default()
786        });
787        let tex_bind_group_layout =
788            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
789                label: Some("tex-layout"),
790                entries: &[
791                    wgpu::BindGroupLayoutEntry {
792                        binding: 0,
793                        visibility: wgpu::ShaderStages::FRAGMENT,
794                        ty: wgpu::BindingType::Texture {
795                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
796                            view_dimension: wgpu::TextureViewDimension::D2,
797                            multisampled: false,
798                        },
799                        count: None,
800                    },
801                    wgpu::BindGroupLayoutEntry {
802                        binding: 1,
803                        visibility: wgpu::ShaderStages::FRAGMENT,
804                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
805                        count: None,
806                    },
807                ],
808            });
809        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
810            label: Some("atlas-bg"),
811            layout: &tex_bind_group_layout,
812            entries: &[
813                wgpu::BindGroupEntry {
814                    binding: 0,
815                    resource: wgpu::BindingResource::TextureView(&atlas_view),
816                },
817                wgpu::BindGroupEntry {
818                    binding: 1,
819                    resource: wgpu::BindingResource::Sampler(&sampler),
820                },
821            ],
822        });
823
824        // Pipeline
825        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
826            label: Some("truce-gpu-pipeline-layout"),
827            bind_group_layouts: &[
828                Some(&viewport_bind_group_layout),
829                Some(&tex_bind_group_layout),
830            ],
831            immediate_size: 0,
832        });
833
834        let vertex_layout = wgpu::VertexBufferLayout {
835            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
836            step_mode: wgpu::VertexStepMode::Vertex,
837            attributes: &[
838                wgpu::VertexAttribute {
839                    offset: 0,
840                    shader_location: 0,
841                    format: wgpu::VertexFormat::Float32x2,
842                },
843                wgpu::VertexAttribute {
844                    offset: 8,
845                    shader_location: 1,
846                    format: wgpu::VertexFormat::Float32x4,
847                },
848                wgpu::VertexAttribute {
849                    offset: 24,
850                    shader_location: 2,
851                    format: wgpu::VertexFormat::Float32x2,
852                },
853                wgpu::VertexAttribute {
854                    offset: 32,
855                    shader_location: 3,
856                    format: wgpu::VertexFormat::Float32,
857                },
858            ],
859        };
860
861        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
862            label: Some("truce-gpu-pipeline"),
863            layout: Some(&pipeline_layout),
864            vertex: wgpu::VertexState {
865                module: &shader,
866                entry_point: Some("vs_main"),
867                buffers: &[vertex_layout],
868                compilation_options: wgpu::PipelineCompilationOptions::default(),
869            },
870            fragment: Some(wgpu::FragmentState {
871                module: &shader,
872                entry_point: Some("fs_main"),
873                targets: &[Some(wgpu::ColorTargetState {
874                    format: target_format,
875                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
876                    write_mask: wgpu::ColorWrites::ALL,
877                })],
878                compilation_options: wgpu::PipelineCompilationOptions::default(),
879            }),
880            primitive: wgpu::PrimitiveState {
881                topology: wgpu::PrimitiveTopology::TriangleList,
882                ..Default::default()
883            },
884            depth_stencil: None,
885            multisample: wgpu::MultisampleState {
886                count: 4,
887                mask: !0,
888                alpha_to_coverage_enabled: false,
889            },
890            multiview_mask: None,
891            cache: None,
892        });
893
894        // MSAA
895        let msaa_texture = Self::create_msaa_view(&device, target_format, width, height);
896
897        let font =
898            fontdue::Font::from_bytes(truce_font::JETBRAINS_MONO, fontdue::FontSettings::default())
899                .expect("failed to parse embedded font");
900
901        Some(Self {
902            device,
903            queue,
904            surface: None,
905            surface_config: None,
906            pipeline,
907            target_format,
908            msaa_texture,
909            msaa_width: width,
910            msaa_height: height,
911            vertices: Vec::with_capacity(4096),
912            indices: Vec::with_capacity(8192),
913            batches: Vec::new(),
914            glyph_atlas: GlyphAtlas::new(),
915            font,
916            atlas_texture,
917            atlas_bind_group,
918            tex_bind_group_layout,
919            sampler,
920            images: Vec::new(),
921            viewport_buffer,
922            viewport_bind_group,
923            clear_color: None,
924            present_clear_default: wgpu::Color::TRANSPARENT,
925            width,
926            height,
927            scale,
928        })
929    }
930
931    /// Prepare for recording a frame of `logical_w × logical_h` logical
932    /// points. The MSAA target and ortho matrix are sized at
933    /// `logical × self.scale()` physical pixels; widget draw calls use
934    /// logical coordinates.
935    ///
936    /// Resets accumulated geometry and the clear flag. Rebuilds the MSAA
937    /// texture if the physical size differs from the previous frame.
938    ///
939    /// Only meaningful when the backend was built via [`Self::new`]; the
940    /// surface-owning constructors drive their own frame lifecycle.
941    #[allow(clippy::cast_precision_loss)]
942    pub fn begin_frame(&mut self, logical_w: u32, logical_h: u32) {
943        let phys_w = truce_gui_types::to_physical_px(logical_w, f64::from(self.scale));
944        let phys_h = truce_gui_types::to_physical_px(logical_h, f64::from(self.scale));
945        self.vertices.clear();
946        self.indices.clear();
947        self.batches.clear();
948        self.clear_color = None;
949
950        if phys_w != self.width || phys_h != self.height {
951            self.width = phys_w;
952            self.height = phys_h;
953            let matrix = ortho_matrix(phys_w as f32, phys_h as f32);
954            self.queue
955                .write_buffer(&self.viewport_buffer, 0, bytemuck::cast_slice(&matrix));
956        }
957
958        if phys_w != self.msaa_width || phys_h != self.msaa_height {
959            self.msaa_texture =
960                Self::create_msaa_view(&self.device, self.target_format, phys_w, phys_h);
961            self.msaa_width = phys_w;
962            self.msaa_height = phys_h;
963        }
964    }
965
966    /// Display scale factor: `logical × scale = physical`. Callers
967    /// sizing sibling GPU resources (e.g. an intermediate texture that
968    /// the backend will resolve into) should use this to stay
969    /// consistent with the backend's raster dimensions.
970    pub fn scale(&self) -> f32 {
971        self.scale
972    }
973
974    /// Update the display scale factor. The next [`Self::resize`] (or
975    /// [`Self::begin_frame`] in headless mode) recomputes physical
976    /// dimensions and reconfigures the surface / MSAA target. Callers
977    /// driving a windowed surface should follow with a `resize` so the
978    /// `surface_config` picks up the new size on the same frame; the
979    /// short-circuit in `resize` doesn't trigger because the scale
980    /// change makes the new physical dims differ from the old.
981    pub fn set_scale(&mut self, scale: f32) {
982        if scale.is_finite() && scale > 0.0 {
983            self.scale = scale;
984        }
985    }
986
987    /// Flush accumulated geometry into a single render pass on `view`,
988    /// recorded into `encoder`. The caller retains ownership of both -
989    /// this method neither submits the encoder nor calls `present()`.
990    ///
991    /// If `clear()` was called since the last `begin_frame`, the pass
992    /// uses `LoadOp::Clear(clear_color)`; otherwise `LoadOp::Load` so
993    /// any prior content in `view` is preserved (the common case when
994    /// widgets overlay a custom render).
995    pub fn finish(&mut self, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
996        self.flush_atlas();
997
998        if self.indices.is_empty() {
999            self.clear_color = None;
1000            return;
1001        }
1002
1003        let vertex_buffer = self
1004            .device
1005            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1006                label: Some("vertices"),
1007                contents: bytemuck::cast_slice(&self.vertices),
1008                usage: wgpu::BufferUsages::VERTEX,
1009            });
1010
1011        let index_buffer = self
1012            .device
1013            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1014                label: Some("indices"),
1015                contents: bytemuck::cast_slice(&self.indices),
1016                usage: wgpu::BufferUsages::INDEX,
1017            });
1018
1019        // MSAA load/store must agree across frames: if we plan to `Load`
1020        // next pass, this pass must `Store` (otherwise the next pass loads
1021        // undefined contents). With a `resolve_target` set, `Discard` is
1022        // standard for MSAA - but it's only well-defined when we also
1023        // `Clear` on entry, since a fresh load after a discard is UB.
1024        let (load, store) = match self.clear_color {
1025            Some(c) => (wgpu::LoadOp::Clear(c), wgpu::StoreOp::Discard),
1026            None => (wgpu::LoadOp::Load, wgpu::StoreOp::Store),
1027        };
1028
1029        {
1030            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1031                label: Some("truce-gpu-frame"),
1032                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1033                    view: &self.msaa_texture,
1034                    resolve_target: Some(view),
1035                    ops: wgpu::Operations { load, store },
1036                    depth_slice: None,
1037                })],
1038                depth_stencil_attachment: None,
1039                timestamp_writes: None,
1040                occlusion_query_set: None,
1041                multiview_mask: None,
1042            });
1043
1044            pass.set_pipeline(&self.pipeline);
1045            pass.set_bind_group(0, &self.viewport_bind_group, &[]);
1046            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1047            pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1048
1049            let total_indices = len_u32(self.indices.len());
1050            if self.batches.is_empty() {
1051                pass.set_bind_group(1, &self.atlas_bind_group, &[]);
1052                pass.draw_indexed(0..total_indices, 0, 0..1);
1053            } else {
1054                for i in 0..self.batches.len() {
1055                    let b = self.batches[i];
1056                    let end = self
1057                        .batches
1058                        .get(i + 1)
1059                        .map_or(total_indices, |n| n.index_start);
1060                    if end <= b.index_start {
1061                        continue;
1062                    }
1063                    let bg = match b.image {
1064                        None => &self.atlas_bind_group,
1065                        Some(img_id) => {
1066                            match self.images.get(img_id.0 as usize).and_then(|s| s.as_ref()) {
1067                                Some(entry) => &entry.bind_group,
1068                                None => continue,
1069                            }
1070                        }
1071                    };
1072                    pass.set_bind_group(1, bg, &[]);
1073                    pass.draw_indexed(b.index_start..end, 0, 0..1);
1074                }
1075            }
1076        }
1077
1078        self.clear_color = None;
1079    }
1080
1081    fn create_msaa_view(
1082        device: &wgpu::Device,
1083        format: wgpu::TextureFormat,
1084        width: u32,
1085        height: u32,
1086    ) -> wgpu::TextureView {
1087        let tex = device.create_texture(&wgpu::TextureDescriptor {
1088            label: Some("msaa"),
1089            size: wgpu::Extent3d {
1090                width,
1091                height,
1092                depth_or_array_layers: 1,
1093            },
1094            mip_level_count: 1,
1095            sample_count: 4,
1096            dimension: wgpu::TextureDimension::D2,
1097            format,
1098            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1099            view_formats: &[],
1100        });
1101        tex.create_view(&wgpu::TextureViewDescriptor::default())
1102    }
1103
1104    fn create_msaa_texture(
1105        device: &wgpu::Device,
1106        config: &wgpu::SurfaceConfiguration,
1107    ) -> wgpu::TextureView {
1108        let tex = device.create_texture(&wgpu::TextureDescriptor {
1109            label: Some("msaa"),
1110            size: wgpu::Extent3d {
1111                width: config.width,
1112                height: config.height,
1113                depth_or_array_layers: 1,
1114            },
1115            mip_level_count: 1,
1116            sample_count: 4,
1117            dimension: wgpu::TextureDimension::D2,
1118            format: config.format,
1119            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1120            view_formats: &[],
1121        });
1122        tex.create_view(&wgpu::TextureViewDescriptor::default())
1123    }
1124
1125    /// Resize the wgpu surface, MSAA texture, and viewport projection.
1126    ///
1127    /// `logical_w` and `logical_h` are in logical points (same coordinate
1128    /// space as `BuiltinEditor::size()`). Returns `true` if the surface
1129    /// was actually reconfigured.
1130    #[allow(clippy::cast_precision_loss)]
1131    pub fn resize(&mut self, logical_w: u32, logical_h: u32) -> bool {
1132        let new_w = truce_gui_types::to_physical_px(logical_w, f64::from(self.scale));
1133        let new_h = truce_gui_types::to_physical_px(logical_h, f64::from(self.scale));
1134        if new_w == self.width && new_h == self.height {
1135            return false;
1136        }
1137        self.width = new_w;
1138        self.height = new_h;
1139
1140        if let Some(ref surface) = self.surface
1141            && let Some(ref mut config) = self.surface_config
1142        {
1143            config.width = new_w;
1144            config.height = new_h;
1145            surface.configure(&self.device, config);
1146            self.msaa_texture = Self::create_msaa_texture(&self.device, config);
1147        }
1148
1149        // Update the orthographic projection matrix.
1150        let matrix = ortho_matrix(new_w as f32, new_h as f32);
1151        self.queue
1152            .write_buffer(&self.viewport_buffer, 0, bytemuck::cast_slice(&matrix));
1153
1154        true
1155    }
1156
1157    // --- Geometry helpers ---
1158
1159    fn color_arr(c: Color) -> [f32; 4] {
1160        [c.r, c.g, c.b, c.a]
1161    }
1162
1163    /// Ensure the current (last) batch targets `image`. If not, close the
1164    /// current batch and open a new one. Call before pushing indices.
1165    fn ensure_batch(&mut self, image: Option<ImageId>) {
1166        let needs_new = self.batches.last().is_none_or(|last| last.image != image);
1167        if needs_new {
1168            self.batches.push(DrawBatch {
1169                index_start: len_u32(self.indices.len()),
1170                image,
1171            });
1172        }
1173    }
1174
1175    fn push_quad(&mut self, v0: Vertex, v1: Vertex, v2: Vertex, v3: Vertex) {
1176        self.ensure_batch(None);
1177        let base = len_u32(self.vertices.len());
1178        self.vertices.extend_from_slice(&[v0, v1, v2, v3]);
1179        self.indices
1180            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1181    }
1182
1183    /// Tessellate a lyon path as a filled shape and append to vertex/index buffers.
1184    fn fill_path(&mut self, path: &Path, color: [f32; 4]) {
1185        self.ensure_batch(None);
1186        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1187        let mut tessellator = FillTessellator::new();
1188        let _ = tessellator.tessellate_path(
1189            path,
1190            &FillOptions::tolerance(0.5),
1191            &mut BuffersBuilder::new(&mut buffers, |vertex: FillVertex| {
1192                let p = vertex.position();
1193                Vertex::solid(p.x, p.y, color)
1194            }),
1195        );
1196        let base = len_u32(self.vertices.len());
1197        self.vertices.extend_from_slice(&buffers.vertices);
1198        self.indices
1199            .extend(buffers.indices.iter().map(|i| i + base));
1200    }
1201
1202    /// Tessellate a lyon path as a stroked shape and append to vertex/index buffers.
1203    fn stroke_path(&mut self, path: &Path, color: [f32; 4], opts: &StrokeOptions) {
1204        self.ensure_batch(None);
1205        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1206        let mut tessellator = StrokeTessellator::new();
1207        let _ = tessellator.tessellate_path(
1208            path,
1209            opts,
1210            &mut BuffersBuilder::new(&mut buffers, |vertex: StrokeVertex| {
1211                let p = vertex.position();
1212                Vertex::solid(p.x, p.y, color)
1213            }),
1214        );
1215        let base = len_u32(self.vertices.len());
1216        self.vertices.extend_from_slice(&buffers.vertices);
1217        self.indices
1218            .extend(buffers.indices.iter().map(|i| i + base));
1219    }
1220
1221    /// Upload pending glyph atlas writes to the GPU.
1222    fn flush_atlas(&mut self) {
1223        for (x, y, w, h, data) in self.glyph_atlas.pending.drain(..) {
1224            if w == 0 || h == 0 {
1225                continue;
1226            }
1227            self.queue.write_texture(
1228                wgpu::TexelCopyTextureInfo {
1229                    texture: &self.atlas_texture,
1230                    mip_level: 0,
1231                    origin: wgpu::Origin3d { x, y, z: 0 },
1232                    aspect: wgpu::TextureAspect::All,
1233                },
1234                &data,
1235                wgpu::TexelCopyBufferLayout {
1236                    offset: 0,
1237                    bytes_per_row: Some(w),
1238                    rows_per_image: Some(h),
1239                },
1240                wgpu::Extent3d {
1241                    width: w,
1242                    height: h,
1243                    depth_or_array_layers: 1,
1244                },
1245            );
1246        }
1247    }
1248}
1249
1250// ---------------------------------------------------------------------------
1251// RenderBackend implementation
1252// ---------------------------------------------------------------------------
1253
1254/// All `RenderBackend` methods accept coordinates in **logical points**.
1255/// The backend multiplies by `self.scale` to get physical pixel positions.
1256/// Font glyphs are rasterized at physical resolution for sharp text.
1257// Rasterizer math uses standard short names (`x`, `y`, `w`, `h`,
1258// `r`, `g`, `b`, `s` = scale, etc.).
1259#[allow(clippy::many_single_char_names)]
1260impl RenderBackend for WgpuBackend {
1261    fn clear(&mut self, color: Color) {
1262        self.clear_color = Some(wgpu::Color {
1263            r: f64::from(color.r),
1264            g: f64::from(color.g),
1265            b: f64::from(color.b),
1266            a: f64::from(color.a),
1267        });
1268        self.vertices.clear();
1269        self.indices.clear();
1270        self.batches.clear();
1271        // If a previous frame hit atlas overflow, do the eviction at the
1272        // frame boundary now - past frames' vertex buffers are gone, so
1273        // dropping the UV cache is safe. Glyphs re-rasterize lazily as
1274        // draw_text walks them this frame.
1275        if self.glyph_atlas.overflow_pending {
1276            self.glyph_atlas.clear();
1277        }
1278    }
1279
1280    fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
1281        let s = self.scale;
1282        let c = Self::color_arr(color);
1283        self.push_quad(
1284            Vertex::solid(x * s, y * s, c),
1285            Vertex::solid((x + w) * s, y * s, c),
1286            Vertex::solid((x + w) * s, (y + h) * s, c),
1287            Vertex::solid(x * s, (y + h) * s, c),
1288        );
1289    }
1290
1291    fn fill_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color) {
1292        let s = self.scale;
1293        let c = Self::color_arr(color);
1294        let mut builder = Path::builder();
1295        builder.add_circle(
1296            point(cx * s, cy * s),
1297            radius * s,
1298            lyon_tessellation::path::Winding::Positive,
1299        );
1300        let path = builder.build();
1301        self.fill_path(&path, c);
1302    }
1303
1304    fn stroke_circle(&mut self, cx: f32, cy: f32, radius: f32, color: Color, width: f32) {
1305        let s = self.scale;
1306        let c = Self::color_arr(color);
1307        let mut builder = Path::builder();
1308        builder.add_circle(
1309            point(cx * s, cy * s),
1310            radius * s,
1311            lyon_tessellation::path::Winding::Positive,
1312        );
1313        let path = builder.build();
1314        let opts = StrokeOptions::tolerance(0.5).with_line_width(width * s);
1315        self.stroke_path(&path, c, &opts);
1316    }
1317
1318    #[allow(clippy::cast_precision_loss)]
1319    fn stroke_arc(
1320        &mut self,
1321        cx: f32,
1322        cy: f32,
1323        radius: f32,
1324        start_angle: f32,
1325        end_angle: f32,
1326        color: Color,
1327        width: f32,
1328    ) {
1329        let s = self.scale;
1330        let c = Self::color_arr(color);
1331        let segments = 64u32;
1332        let sweep = end_angle - start_angle;
1333        let step = sweep / segments as f32;
1334
1335        let mut builder = Path::builder();
1336        builder.begin(point(
1337            cx * s + radius * s * start_angle.cos(),
1338            cy * s + radius * s * start_angle.sin(),
1339        ));
1340        for i in 1..=segments {
1341            let angle = start_angle + step * i as f32;
1342            builder.line_to(point(
1343                cx * s + radius * s * angle.cos(),
1344                cy * s + radius * s * angle.sin(),
1345            ));
1346        }
1347        builder.end(false);
1348        let path = builder.build();
1349
1350        let opts = StrokeOptions::tolerance(0.5)
1351            .with_line_width(width * s)
1352            .with_line_cap(lyon_tessellation::LineCap::Round);
1353        self.stroke_path(&path, c, &opts);
1354    }
1355
1356    fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, color: Color, width: f32) {
1357        let s = self.scale;
1358        let c = Self::color_arr(color);
1359        let mut builder = Path::builder();
1360        builder.begin(point(x1 * s, y1 * s));
1361        builder.line_to(point(x2 * s, y2 * s));
1362        builder.end(false);
1363        let path = builder.build();
1364
1365        let opts = StrokeOptions::tolerance(0.5)
1366            .with_line_width(width * s)
1367            .with_line_cap(lyon_tessellation::LineCap::Round);
1368        self.stroke_path(&path, c, &opts);
1369    }
1370
1371    // Glyph cache key uses `(phys_size * 10.0) as u32` quantization,
1372    // matching `ensure_glyph`. Window-bounded coordinates fit in u32.
1373    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1374    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: Color) {
1375        let s = self.scale;
1376        let phys_size = size * s;
1377        let c = Self::color_arr(color);
1378        let line_metrics = self.font.horizontal_line_metrics(phys_size);
1379        let ascent = line_metrics.map_or(phys_size * 0.8, |m| m.ascent);
1380
1381        let mut cursor_x = x * s;
1382
1383        let chars: Vec<char> = text.chars().collect();
1384        for &ch in &chars {
1385            self.glyph_atlas.ensure_glyph(&self.font, ch, phys_size);
1386        }
1387
1388        // `.get` rather than `[..]` - when the atlas overflows mid-frame,
1389        // `ensure_glyph` skips the insert (see GlyphAtlas::ensure_glyph)
1390        // and we want to drop the missing glyph silently rather than
1391        // panic on lookup. The next frame clears the atlas and these
1392        // glyphs come back.
1393        for &ch in &chars {
1394            let key = (ch, (phys_size * 10.0) as u32);
1395            let Some(g) = self.glyph_atlas.glyphs.get(&key) else {
1396                continue;
1397            };
1398            let (u0, v0, u1, v1, gw, gh, y_off, advance) = (
1399                g.u0, g.v0, g.u1, g.v1, g.width, g.height, g.y_offset, g.advance,
1400            );
1401            // Snap the glyph quad to integer pixel positions on emit.
1402            // Atlas texels and screen pixels are 1:1 (glyphs are
1403            // rasterized at `phys_size`); the shared sampler is
1404            // `FilterMode::Linear`, so a fractional `gx`/`gy` would
1405            // produce per-output-pixel UV interpolation that mixes
1406            // neighbouring atlas texels and smears the glyph. Snapping
1407            // on emit (while `cursor_x` keeps full f32 advance) yields
1408            // crisp glyphs with the kerning error capped at ±0.5px,
1409            // invisible at UI text sizes.
1410            let gx = cursor_x.round();
1411            let gy = (y * s + ascent - y_off - gh).round();
1412
1413            self.push_quad(
1414                Vertex::glyph(gx, gy, c, u0, v0),
1415                Vertex::glyph(gx + gw, gy, c, u1, v0),
1416                Vertex::glyph(gx + gw, gy + gh, c, u1, v1),
1417                Vertex::glyph(gx, gy + gh, c, u0, v1),
1418            );
1419
1420            cursor_x += advance;
1421        }
1422    }
1423
1424    fn text_width(&self, text: &str, size: f32) -> f32 {
1425        let phys_size = size * self.scale;
1426        // Sum advance widths via the local fontdue instance. This used
1427        // to delegate to `truce_gui::font::text_width_fontdue` (a
1428        // glyph-cached version); doing the math inline keeps
1429        // truce-gpu independent of truce-gui.
1430        let phys: f32 = text
1431            .chars()
1432            .map(|ch| self.font.metrics(ch, phys_size).advance_width)
1433            .sum();
1434        phys / self.scale
1435    }
1436
1437    fn register_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
1438        let expected = (width as usize) * (height as usize) * 4;
1439        if width == 0 || height == 0 || rgba.len() < expected {
1440            return ImageId::INVALID;
1441        }
1442
1443        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1444            label: Some("image"),
1445            size: wgpu::Extent3d {
1446                width,
1447                height,
1448                depth_or_array_layers: 1,
1449            },
1450            mip_level_count: 1,
1451            sample_count: 1,
1452            dimension: wgpu::TextureDimension::D2,
1453            format: wgpu::TextureFormat::Rgba8Unorm,
1454            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1455            view_formats: &[],
1456        });
1457
1458        self.queue.write_texture(
1459            wgpu::TexelCopyTextureInfo {
1460                texture: &texture,
1461                mip_level: 0,
1462                origin: wgpu::Origin3d::ZERO,
1463                aspect: wgpu::TextureAspect::All,
1464            },
1465            &rgba[..expected],
1466            wgpu::TexelCopyBufferLayout {
1467                offset: 0,
1468                bytes_per_row: Some(width * 4),
1469                rows_per_image: Some(height),
1470            },
1471            wgpu::Extent3d {
1472                width,
1473                height,
1474                depth_or_array_layers: 1,
1475            },
1476        );
1477
1478        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1479        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1480            label: Some("image-bg"),
1481            layout: &self.tex_bind_group_layout,
1482            entries: &[
1483                wgpu::BindGroupEntry {
1484                    binding: 0,
1485                    resource: wgpu::BindingResource::TextureView(&view),
1486                },
1487                wgpu::BindGroupEntry {
1488                    binding: 1,
1489                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1490                },
1491            ],
1492        });
1493
1494        let entry = ImageEntry {
1495            _texture: texture,
1496            bind_group,
1497        };
1498
1499        if let Some((idx, slot)) = self
1500            .images
1501            .iter_mut()
1502            .enumerate()
1503            .find(|(_, s)| s.is_none())
1504        {
1505            *slot = Some(entry);
1506            return ImageId(len_u32(idx));
1507        }
1508        let id = len_u32(self.images.len());
1509        self.images.push(Some(entry));
1510        ImageId(id)
1511    }
1512
1513    fn unregister_image(&mut self, id: ImageId) {
1514        if let Some(slot) = self.images.get_mut(id.0 as usize) {
1515            *slot = None;
1516        }
1517    }
1518
1519    fn draw_image(&mut self, id: ImageId, x: f32, y: f32, w: f32, h: f32) {
1520        if self
1521            .images
1522            .get(id.0 as usize)
1523            .and_then(|s| s.as_ref())
1524            .is_none()
1525        {
1526            return;
1527        }
1528        self.ensure_batch(Some(id));
1529
1530        let s = self.scale;
1531        let c = [1.0, 1.0, 1.0, 1.0];
1532        let base = len_u32(self.vertices.len());
1533        self.vertices.extend_from_slice(&[
1534            Vertex::image(x * s, y * s, c, 0.0, 0.0),
1535            Vertex::image((x + w) * s, y * s, c, 1.0, 0.0),
1536            Vertex::image((x + w) * s, (y + h) * s, c, 1.0, 1.0),
1537            Vertex::image(x * s, (y + h) * s, c, 0.0, 1.0),
1538        ]);
1539        self.indices
1540            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1541    }
1542
1543    fn present(&mut self) {
1544        // Upload any pending glyph atlas writes (before borrowing surface)
1545        self.flush_atlas();
1546
1547        let Some(surface) = &self.surface else {
1548            return; // headless - no surface to present to
1549        };
1550
1551        // Acquire a swapchain frame, recovering from a stale surface.
1552        // After a window resize on X11/Vulkan the surface reports
1553        // `Outdated` and stays that way until it is reconfigured - even
1554        // reconfiguring to the same size clears the flag, so a plain
1555        // skip-the-frame would freeze the editor on its pre-resize image
1556        // with the desktop showing through the newly exposed area. On
1557        // `Outdated` / `Lost` / `Validation` we reconfigure and retry;
1558        // `Timeout` / `Occluded` are transient, so we skip this frame.
1559        let mut acquired = None;
1560        for _ in 0..2 {
1561            match surface.get_current_texture() {
1562                wgpu::CurrentSurfaceTexture::Success(frame)
1563                | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
1564                    acquired = Some(frame);
1565                    break;
1566                }
1567                wgpu::CurrentSurfaceTexture::Outdated
1568                | wgpu::CurrentSurfaceTexture::Lost
1569                | wgpu::CurrentSurfaceTexture::Validation => {
1570                    if let Some(config) = &self.surface_config {
1571                        surface.configure(&self.device, config);
1572                    }
1573                }
1574                wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
1575                    return;
1576                }
1577            }
1578        }
1579        let Some(frame) = acquired else {
1580            return;
1581        };
1582        let frame_view = frame
1583            .texture
1584            .create_view(&wgpu::TextureViewDescriptor::default());
1585
1586        if self.vertices.is_empty() {
1587            // No draws this frame, but the surface is double/triple-buffered
1588            // - without a render pass the swap chain shows whatever was
1589            // there before (often the second-prior frame, producing a
1590            // visible flicker on idle). Issue a clear-only pass so the
1591            // surface ends up at `clear_color`.
1592            self.clear_only_pass(&frame_view);
1593            frame.present();
1594            return;
1595        }
1596
1597        self.render_pass(&frame_view);
1598        frame.present();
1599    }
1600}
1601
1602impl WgpuBackend {
1603    /// Issue a render pass that only clears the surface. Used by
1604    /// `present()` when there is no geometry - without it the swap chain
1605    /// would show stale buffer contents. Always clears (`Load` would
1606    /// surface prior-frame garbage), falling back to
1607    /// `present_clear_default` when no `clear()` was requested.
1608    fn clear_only_pass(&mut self, resolve_target: &wgpu::TextureView) {
1609        let clear_color = self.clear_color.unwrap_or(self.present_clear_default);
1610        let mut encoder = self
1611            .device
1612            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1613                label: Some("clear-only"),
1614            });
1615        {
1616            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1617                label: Some("clear-only"),
1618                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1619                    view: &self.msaa_texture,
1620                    resolve_target: Some(resolve_target),
1621                    ops: wgpu::Operations {
1622                        load: wgpu::LoadOp::Clear(clear_color),
1623                        store: wgpu::StoreOp::Discard,
1624                    },
1625                    depth_slice: None,
1626                })],
1627                depth_stencil_attachment: None,
1628                timestamp_writes: None,
1629                occlusion_query_set: None,
1630                multiview_mask: None,
1631            });
1632        }
1633        self.queue.submit(std::iter::once(encoder.finish()));
1634    }
1635
1636    /// Render accumulated geometry to a texture view (shared by present + headless).
1637    fn render_pass(&mut self, resolve_target: &wgpu::TextureView) {
1638        let clear_color = self.clear_color.unwrap_or(self.present_clear_default);
1639        let vertex_buffer = self
1640            .device
1641            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1642                label: Some("vertices"),
1643                contents: bytemuck::cast_slice(&self.vertices),
1644                usage: wgpu::BufferUsages::VERTEX,
1645            });
1646
1647        let index_buffer = self
1648            .device
1649            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1650                label: Some("indices"),
1651                contents: bytemuck::cast_slice(&self.indices),
1652                usage: wgpu::BufferUsages::INDEX,
1653            });
1654
1655        let mut encoder = self
1656            .device
1657            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1658                label: Some("frame"),
1659            });
1660
1661        {
1662            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1663                label: Some("main"),
1664                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1665                    view: &self.msaa_texture,
1666                    resolve_target: Some(resolve_target),
1667                    ops: wgpu::Operations {
1668                        load: wgpu::LoadOp::Clear(clear_color),
1669                        store: wgpu::StoreOp::Discard,
1670                    },
1671                    depth_slice: None,
1672                })],
1673                depth_stencil_attachment: None,
1674                timestamp_writes: None,
1675                occlusion_query_set: None,
1676                multiview_mask: None,
1677            });
1678
1679            pass.set_pipeline(&self.pipeline);
1680            pass.set_bind_group(0, &self.viewport_bind_group, &[]);
1681            pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1682            pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1683
1684            let total_indices = len_u32(self.indices.len());
1685            if self.batches.is_empty() {
1686                // Backwards-compatible path: no batching recorded (e.g. a
1687                // caller that bypassed clear()). Draw everything with the
1688                // atlas bind group.
1689                pass.set_bind_group(1, &self.atlas_bind_group, &[]);
1690                pass.draw_indexed(0..total_indices, 0, 0..1);
1691            } else {
1692                for i in 0..self.batches.len() {
1693                    let b = self.batches[i];
1694                    let end = self
1695                        .batches
1696                        .get(i + 1)
1697                        .map_or(total_indices, |n| n.index_start);
1698                    if end <= b.index_start {
1699                        continue;
1700                    }
1701                    let bg = match b.image {
1702                        None => &self.atlas_bind_group,
1703                        Some(img_id) => {
1704                            match self.images.get(img_id.0 as usize).and_then(|s| s.as_ref()) {
1705                                Some(entry) => &entry.bind_group,
1706                                // Image was unregistered mid-frame; skip draw.
1707                                None => continue,
1708                            }
1709                        }
1710                    };
1711                    pass.set_bind_group(1, bg, &[]);
1712                    pass.draw_indexed(b.index_start..end, 0, 0..1);
1713                }
1714            }
1715        }
1716
1717        self.queue.submit(std::iter::once(encoder.finish()));
1718    }
1719
1720    /// Create a headless GPU backend (no window or surface).
1721    /// Used for snapshot testing.
1722    ///
1723    /// # Panics
1724    ///
1725    /// Panics if the embedded font fails to parse (bundled-asset
1726    /// bug, never user input).
1727    // Surface dimensions in pixels stay below 2^23, well within f32.
1728    #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
1729    #[must_use]
1730    pub fn headless(width: u32, height: u32, scale: f32) -> Option<Self> {
1731        let phys_w = truce_gui_types::to_physical_px(width, f64::from(scale));
1732        let phys_h = truce_gui_types::to_physical_px(height, f64::from(scale));
1733
1734        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
1735        desc.backends = wgpu::Backends::PRIMARY;
1736        let instance = wgpu::Instance::new(desc);
1737
1738        // Headless: there is no `wgpu::Surface` to constrain the adapter
1739        // pick against, so `compatible_surface` is `None`. On a multi-GPU
1740        // host (e.g. discrete + integrated, or NVIDIA Optimus) wgpu may
1741        // pick a different physical adapter than the live render path's
1742        // `compatible_surface: Some(&surface)`, which can produce subtle
1743        // rasterization differences (driver-specific shader compile, MSAA
1744        // resolve, sRGB rounding). Bake screenshot baselines on the host
1745        // you gate from - this is the same constraint already documented
1746        // in `cargo truce screenshot --check`.
1747        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1748            power_preference: wgpu::PowerPreference::HighPerformance,
1749            compatible_surface: None,
1750            force_fallback_adapter: false,
1751        }))
1752        .ok()?;
1753
1754        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
1755            label: Some("truce-gpu-headless"),
1756            required_features: wgpu::Features::empty(),
1757            required_limits: wgpu::Limits::downlevel_defaults(),
1758            experimental_features: wgpu::ExperimentalFeatures::default(),
1759            memory_hints: wgpu::MemoryHints::Performance,
1760            trace: wgpu::Trace::Off,
1761        }))
1762        .ok()?;
1763        let device = Arc::new(device);
1764        let queue = Arc::new(queue);
1765
1766        // Use non-sRGB to match the windowed path (which picks !is_srgb())
1767        let texture_format = wgpu::TextureFormat::Rgba8Unorm;
1768
1769        // MSAA texture
1770        let msaa_texture = device.create_texture(&wgpu::TextureDescriptor {
1771            label: Some("msaa"),
1772            size: wgpu::Extent3d {
1773                width: phys_w,
1774                height: phys_h,
1775                depth_or_array_layers: 1,
1776            },
1777            mip_level_count: 1,
1778            sample_count: 4,
1779            dimension: wgpu::TextureDimension::D2,
1780            format: texture_format,
1781            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1782            view_formats: &[],
1783        });
1784        let msaa_view = msaa_texture.create_view(&wgpu::TextureViewDescriptor::default());
1785
1786        // Shader
1787        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1788            label: Some("truce-gpu-shader"),
1789            source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
1790        });
1791
1792        // Viewport
1793        let matrix = ortho_matrix(phys_w as f32, phys_h as f32);
1794        let viewport_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1795            label: Some("viewport"),
1796            contents: bytemuck::cast_slice(&matrix),
1797            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1798        });
1799
1800        let viewport_bind_group_layout =
1801            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1802                label: Some("viewport-layout"),
1803                entries: &[wgpu::BindGroupLayoutEntry {
1804                    binding: 0,
1805                    visibility: wgpu::ShaderStages::VERTEX,
1806                    ty: wgpu::BindingType::Buffer {
1807                        ty: wgpu::BufferBindingType::Uniform,
1808                        has_dynamic_offset: false,
1809                        min_binding_size: None,
1810                    },
1811                    count: None,
1812                }],
1813            });
1814
1815        let viewport_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1816            label: Some("viewport-bg"),
1817            layout: &viewport_bind_group_layout,
1818            entries: &[wgpu::BindGroupEntry {
1819                binding: 0,
1820                resource: viewport_buffer.as_entire_binding(),
1821            }],
1822        });
1823
1824        // Atlas
1825        let atlas_texture = device.create_texture(&wgpu::TextureDescriptor {
1826            label: Some("glyph-atlas"),
1827            size: wgpu::Extent3d {
1828                width: ATLAS_SIZE,
1829                height: ATLAS_SIZE,
1830                depth_or_array_layers: 1,
1831            },
1832            mip_level_count: 1,
1833            sample_count: 1,
1834            dimension: wgpu::TextureDimension::D2,
1835            format: wgpu::TextureFormat::R8Unorm,
1836            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1837            view_formats: &[],
1838        });
1839        let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
1840        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1841            mag_filter: wgpu::FilterMode::Linear,
1842            min_filter: wgpu::FilterMode::Linear,
1843            ..Default::default()
1844        });
1845        let tex_bind_group_layout =
1846            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1847                label: Some("tex-layout"),
1848                entries: &[
1849                    wgpu::BindGroupLayoutEntry {
1850                        binding: 0,
1851                        visibility: wgpu::ShaderStages::FRAGMENT,
1852                        ty: wgpu::BindingType::Texture {
1853                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
1854                            view_dimension: wgpu::TextureViewDimension::D2,
1855                            multisampled: false,
1856                        },
1857                        count: None,
1858                    },
1859                    wgpu::BindGroupLayoutEntry {
1860                        binding: 1,
1861                        visibility: wgpu::ShaderStages::FRAGMENT,
1862                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1863                        count: None,
1864                    },
1865                ],
1866            });
1867        let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1868            label: Some("atlas-bg"),
1869            layout: &tex_bind_group_layout,
1870            entries: &[
1871                wgpu::BindGroupEntry {
1872                    binding: 0,
1873                    resource: wgpu::BindingResource::TextureView(&atlas_view),
1874                },
1875                wgpu::BindGroupEntry {
1876                    binding: 1,
1877                    resource: wgpu::BindingResource::Sampler(&sampler),
1878                },
1879            ],
1880        });
1881
1882        // Pipeline
1883        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1884            label: Some("truce-gpu-pipeline-layout"),
1885            bind_group_layouts: &[
1886                Some(&viewport_bind_group_layout),
1887                Some(&tex_bind_group_layout),
1888            ],
1889            immediate_size: 0,
1890        });
1891
1892        let vertex_layout = wgpu::VertexBufferLayout {
1893            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
1894            step_mode: wgpu::VertexStepMode::Vertex,
1895            attributes: &[
1896                wgpu::VertexAttribute {
1897                    offset: 0,
1898                    shader_location: 0,
1899                    format: wgpu::VertexFormat::Float32x2,
1900                },
1901                wgpu::VertexAttribute {
1902                    offset: 8,
1903                    shader_location: 1,
1904                    format: wgpu::VertexFormat::Float32x4,
1905                },
1906                wgpu::VertexAttribute {
1907                    offset: 24,
1908                    shader_location: 2,
1909                    format: wgpu::VertexFormat::Float32x2,
1910                },
1911                wgpu::VertexAttribute {
1912                    offset: 32,
1913                    shader_location: 3,
1914                    format: wgpu::VertexFormat::Float32,
1915                },
1916            ],
1917        };
1918
1919        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1920            label: Some("truce-gpu-pipeline"),
1921            layout: Some(&pipeline_layout),
1922            vertex: wgpu::VertexState {
1923                module: &shader,
1924                entry_point: Some("vs_main"),
1925                buffers: &[vertex_layout],
1926                compilation_options: wgpu::PipelineCompilationOptions::default(),
1927            },
1928            fragment: Some(wgpu::FragmentState {
1929                module: &shader,
1930                entry_point: Some("fs_main"),
1931                targets: &[Some(wgpu::ColorTargetState {
1932                    format: texture_format,
1933                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1934                    write_mask: wgpu::ColorWrites::ALL,
1935                })],
1936                compilation_options: wgpu::PipelineCompilationOptions::default(),
1937            }),
1938            primitive: wgpu::PrimitiveState {
1939                topology: wgpu::PrimitiveTopology::TriangleList,
1940                ..Default::default()
1941            },
1942            depth_stencil: None,
1943            multisample: wgpu::MultisampleState {
1944                count: 4,
1945                mask: !0,
1946                alpha_to_coverage_enabled: false,
1947            },
1948            multiview_mask: None,
1949            cache: None,
1950        });
1951
1952        let font =
1953            fontdue::Font::from_bytes(truce_font::JETBRAINS_MONO, fontdue::FontSettings::default())
1954                .expect("failed to parse embedded font");
1955
1956        Some(Self {
1957            device,
1958            queue,
1959            surface: None,
1960            surface_config: None,
1961            pipeline,
1962            target_format: texture_format,
1963            msaa_texture: msaa_view,
1964            msaa_width: phys_w,
1965            msaa_height: phys_h,
1966            vertices: Vec::with_capacity(4096),
1967            indices: Vec::with_capacity(8192),
1968            batches: Vec::new(),
1969            glyph_atlas: GlyphAtlas::new(),
1970            font,
1971            atlas_texture,
1972            atlas_bind_group,
1973            tex_bind_group_layout,
1974            sampler,
1975            images: Vec::new(),
1976            viewport_buffer,
1977            viewport_bind_group,
1978            clear_color: None,
1979            present_clear_default: wgpu::Color::BLACK,
1980            width: phys_w,
1981            height: phys_h,
1982            scale,
1983        })
1984    }
1985
1986    /// Render to an offscreen texture and read back RGBA pixels.
1987    /// Only works for headless backends (no surface).
1988    ///
1989    /// # Panics
1990    ///
1991    /// Panics if `wgpu::Buffer::map_async` reports failure when reading
1992    /// back the GPU readback buffer - that indicates an adapter / driver
1993    /// fault rather than a recoverable runtime condition, so the
1994    /// snapshot path bubbles it up rather than papering over it.
1995    pub fn read_pixels(&mut self) -> Vec<u8> {
1996        self.flush_atlas();
1997
1998        let w = self.width;
1999        let h = self.height;
2000        let format = wgpu::TextureFormat::Rgba8Unorm;
2001
2002        // Offscreen resolve target
2003        let target_texture = self.device.create_texture(&wgpu::TextureDescriptor {
2004            label: Some("offscreen"),
2005            size: wgpu::Extent3d {
2006                width: w,
2007                height: h,
2008                depth_or_array_layers: 1,
2009            },
2010            mip_level_count: 1,
2011            sample_count: 1,
2012            dimension: wgpu::TextureDimension::D2,
2013            format,
2014            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2015            view_formats: &[],
2016        });
2017        let target_view = target_texture.create_view(&wgpu::TextureViewDescriptor::default());
2018
2019        // Render
2020        if !self.vertices.is_empty() {
2021            self.render_pass(&target_view);
2022        }
2023
2024        // Readback
2025        let bytes_per_row = (w * 4 + 255) & !255; // 256-byte aligned
2026        let readback_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
2027            label: Some("readback"),
2028            size: u64::from(bytes_per_row * h),
2029            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2030            mapped_at_creation: false,
2031        });
2032
2033        let mut encoder = self
2034            .device
2035            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2036                label: Some("readback"),
2037            });
2038        encoder.copy_texture_to_buffer(
2039            wgpu::TexelCopyTextureInfo {
2040                texture: &target_texture,
2041                mip_level: 0,
2042                origin: wgpu::Origin3d::ZERO,
2043                aspect: wgpu::TextureAspect::All,
2044            },
2045            wgpu::TexelCopyBufferInfo {
2046                buffer: &readback_buf,
2047                layout: wgpu::TexelCopyBufferLayout {
2048                    offset: 0,
2049                    bytes_per_row: Some(bytes_per_row),
2050                    rows_per_image: None,
2051                },
2052            },
2053            wgpu::Extent3d {
2054                width: w,
2055                height: h,
2056                depth_or_array_layers: 1,
2057            },
2058        );
2059        self.queue.submit(std::iter::once(encoder.finish()));
2060
2061        // Map and copy
2062        let buf_slice = readback_buf.slice(..);
2063        let (tx, rx) = std::sync::mpsc::channel();
2064        buf_slice.map_async(wgpu::MapMode::Read, move |result| {
2065            tx.send(result).unwrap();
2066        });
2067        let _ = self.device.poll(wgpu::PollType::Wait {
2068            submission_index: None,
2069            timeout: None,
2070        });
2071        rx.recv().unwrap().expect("buffer map failed");
2072
2073        let mapped = buf_slice.get_mapped_range();
2074        let mut pixels = Vec::with_capacity((w * h * 4) as usize);
2075        for row in 0..h {
2076            let start = (row * bytes_per_row) as usize;
2077            let end = start + (w * 4) as usize;
2078            pixels.extend_from_slice(&mapped[start..end]);
2079        }
2080        drop(mapped);
2081        readback_buf.unmap();
2082
2083        // Shader writes premultiplied alpha (BlendState::PREMULTIPLIED_ALPHA_BLENDING),
2084        // but downstream consumers - `truce-test`'s reference-PNG comparison
2085        // and `cargo truce screenshot` output - assume straight RGBA, the
2086        // same convention `truce-slint::screenshot::render_with_state` uses.
2087        // Un-premultiply here so the GPU readback matches the headless
2088        // contract instead of leaking the GPU's internal format.
2089        for px in pixels.chunks_exact_mut(4) {
2090            let a = px[3];
2091            if a == 0 || a == 255 {
2092                continue;
2093            }
2094            let a16 = u16::from(a);
2095            // Round to nearest: (c * 255 + a/2) / a.
2096            px[0] = ((u16::from(px[0]) * 255 + a16 / 2) / a16).min(255) as u8;
2097            px[1] = ((u16::from(px[1]) * 255 + a16 / 2) / a16).min(255) as u8;
2098            px[2] = ((u16::from(px[2]) * 255 + a16 / 2) / a16).min(255) as u8;
2099        }
2100
2101        pixels
2102    }
2103}
2104
2105// ---------------------------------------------------------------------------
2106// Tests
2107// ---------------------------------------------------------------------------
2108
2109#[cfg(test)]
2110mod tests {
2111    use super::*;
2112
2113    #[test]
2114    fn vertex_size() {
2115        // 2 (pos) + 4 (color) + 2 (uv) + 1 (tex_mix) + 1 (pad) = 10 floats = 40 bytes
2116        let size = std::mem::size_of::<Vertex>();
2117        assert!(size > 0, "Vertex should have non-zero size: {size}");
2118    }
2119
2120    // Both ortho-matrix tests check that the helper maps top-left
2121    // screen-space origin (0, 0) to wgpu's top-left clip-space corner
2122    // (-1, +1) and bottom-right screen (w, h) to clip (+1, -1). The Y
2123    // flip is the `-2.0 / h` term in `ortho_matrix` - without it,
2124    // increasing screen-y would move the vertex *up* in clip space.
2125    #[test]
2126    fn ortho_matrix_maps_origin() {
2127        let m = ortho_matrix(800.0, 600.0);
2128        let x = m[0][0] * 0.0 + m[3][0];
2129        let y = m[1][1] * 0.0 + m[3][1];
2130        assert!((x - (-1.0)).abs() < 1e-6);
2131        assert!((y - 1.0).abs() < 1e-6);
2132    }
2133
2134    #[test]
2135    fn ortho_matrix_maps_bottom_right() {
2136        let m = ortho_matrix(800.0, 600.0);
2137        let x = m[0][0] * 800.0 + m[3][0];
2138        let y = m[1][1] * 600.0 + m[3][1];
2139        assert!((x - 1.0).abs() < 1e-6);
2140        assert!((y - (-1.0)).abs() < 1e-6);
2141    }
2142
2143    #[test]
2144    fn glyph_atlas_shelf_packing() {
2145        let font =
2146            fontdue::Font::from_bytes(truce_font::JETBRAINS_MONO, fontdue::FontSettings::default())
2147                .unwrap();
2148        let mut atlas = GlyphAtlas::new();
2149
2150        // Pack a few glyphs
2151        atlas.ensure_glyph(&font, 'A', 14.0);
2152        atlas.ensure_glyph(&font, 'B', 14.0);
2153        atlas.ensure_glyph(&font, 'C', 14.0);
2154
2155        assert_eq!(atlas.glyphs.len(), 3);
2156        assert!(!atlas.pending.is_empty());
2157
2158        // Same glyph at same size should not create a new entry
2159        atlas.ensure_glyph(&font, 'A', 14.0);
2160        assert_eq!(atlas.glyphs.len(), 3);
2161    }
2162
2163    #[test]
2164    fn lyon_fill_circle_produces_triangles() {
2165        let mut builder = Path::builder();
2166        builder.add_circle(
2167            point(50.0, 50.0),
2168            10.0,
2169            lyon_tessellation::path::Winding::Positive,
2170        );
2171        let path = builder.build();
2172        let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
2173        let mut tess = FillTessellator::new();
2174        tess.tessellate_path(
2175            &path,
2176            &FillOptions::tolerance(0.5),
2177            &mut BuffersBuilder::new(&mut buffers, |v: FillVertex| {
2178                let p = v.position();
2179                [p.x, p.y]
2180            }),
2181        )
2182        .unwrap();
2183        assert!(buffers.vertices.len() >= 3);
2184        assert!(buffers.indices.len() >= 3);
2185    }
2186
2187    /// End-to-end smoke test for the standalone `new` / `begin_frame` /
2188    /// `finish` path: build a backend against a caller-owned device,
2189    /// record some primitives + text, render into an offscreen texture,
2190    /// and verify we wrote non-background pixels.
2191    #[test]
2192    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
2193    fn standalone_pipeline_renders() {
2194        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
2195        desc.backends = wgpu::Backends::PRIMARY;
2196        let instance = wgpu::Instance::new(desc);
2197        let Ok(adapter) =
2198            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
2199                power_preference: wgpu::PowerPreference::HighPerformance,
2200                compatible_surface: None,
2201                force_fallback_adapter: false,
2202            }))
2203        else {
2204            return; // no GPU in this environment
2205        };
2206        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
2207            label: Some("standalone-test"),
2208            required_features: wgpu::Features::empty(),
2209            required_limits: wgpu::Limits::downlevel_defaults(),
2210            experimental_features: wgpu::ExperimentalFeatures::default(),
2211            memory_hints: wgpu::MemoryHints::Performance,
2212            trace: wgpu::Trace::Off,
2213        }))
2214        .expect("request_device");
2215        let device = Arc::new(device);
2216        let queue = Arc::new(queue);
2217
2218        let w = 64u32;
2219        let h = 48u32;
2220        let format = wgpu::TextureFormat::Rgba8Unorm;
2221        let mut backend =
2222            WgpuBackend::new(Arc::clone(&device), Arc::clone(&queue), format, w, h, 1.0)
2223                .expect("backend new");
2224
2225        // Pre-fill the offscreen target with red so we can tell apart
2226        // "finish drew something" from "finish cleared to background".
2227        let target = device.create_texture(&wgpu::TextureDescriptor {
2228            label: Some("standalone-target"),
2229            size: wgpu::Extent3d {
2230                width: w,
2231                height: h,
2232                depth_or_array_layers: 1,
2233            },
2234            mip_level_count: 1,
2235            sample_count: 1,
2236            dimension: wgpu::TextureDimension::D2,
2237            format,
2238            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2239            view_formats: &[],
2240        });
2241        let view = target.create_view(&wgpu::TextureViewDescriptor::default());
2242
2243        backend.begin_frame(w, h);
2244        backend.clear(Color::rgb(0.0, 0.0, 0.0));
2245        backend.fill_rect(8.0, 8.0, 16.0, 16.0, Color::rgb(0.0, 1.0, 0.0));
2246        backend.draw_text("x", 20.0, 20.0, 14.0, Color::rgb(1.0, 1.0, 1.0));
2247
2248        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2249            label: Some("standalone-enc"),
2250        });
2251        backend.finish(&mut encoder, &view);
2252
2253        // Copy target to a readback buffer and inspect.
2254        let bytes_per_row = (w * 4 + 255) & !255;
2255        let readback = device.create_buffer(&wgpu::BufferDescriptor {
2256            label: Some("readback"),
2257            size: u64::from(bytes_per_row * h),
2258            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2259            mapped_at_creation: false,
2260        });
2261        encoder.copy_texture_to_buffer(
2262            wgpu::TexelCopyTextureInfo {
2263                texture: &target,
2264                mip_level: 0,
2265                origin: wgpu::Origin3d::ZERO,
2266                aspect: wgpu::TextureAspect::All,
2267            },
2268            wgpu::TexelCopyBufferInfo {
2269                buffer: &readback,
2270                layout: wgpu::TexelCopyBufferLayout {
2271                    offset: 0,
2272                    bytes_per_row: Some(bytes_per_row),
2273                    rows_per_image: None,
2274                },
2275            },
2276            wgpu::Extent3d {
2277                width: w,
2278                height: h,
2279                depth_or_array_layers: 1,
2280            },
2281        );
2282        queue.submit(std::iter::once(encoder.finish()));
2283
2284        let slice = readback.slice(..);
2285        let (tx, rx) = std::sync::mpsc::channel();
2286        slice.map_async(wgpu::MapMode::Read, move |r| {
2287            tx.send(r).unwrap();
2288        });
2289        let _ = device.poll(wgpu::PollType::Wait {
2290            submission_index: None,
2291            timeout: None,
2292        });
2293        rx.recv().unwrap().unwrap();
2294        let mapped = slice.get_mapped_range();
2295
2296        // Probe the green rect center (16, 16) - should be ~green.
2297        let row_off = 16usize * bytes_per_row as usize;
2298        let px_off = row_off + 16 * 4;
2299        let r = mapped[px_off];
2300        let g = mapped[px_off + 1];
2301        let b = mapped[px_off + 2];
2302        assert!(g > 200, "green rect not rendered: got rgb=({r},{g},{b})");
2303        assert!(
2304            r < 50 && b < 50,
2305            "green rect leaked other channels: rgb=({r},{g},{b})"
2306        );
2307    }
2308}