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