Skip to main content

damascene_wgpu/
lib.rs

1//! `wgpu` backend for custom Damascene hosts.
2//!
3//! Most applications should implement `damascene_core::App` and run it
4//! through `damascene-winit-wgpu`. Use this crate directly when you are
5//! writing your own host, embedding Damascene into an existing `wgpu`
6//! renderer, or producing headless render artifacts.
7//!
8//! The public entry point is [`Runner`]. It owns:
9//!
10//! - GPU resources: pipelines, buffers, text atlas, and icon atlas.
11//! - Backend-agnostic interaction state shared through
12//!   `damascene_core::runtime::RunnerCore`.
13//! - A snapshot of the last laid-out tree so input arriving between
14//!   frames hit-tests against the geometry the user can see.
15//!
16//! # Custom host loop
17//!
18//! The runner does not own the device, queue, swapchain, window, or
19//! event loop. A host creates those resources, forwards input into the
20//! runner, builds a fresh `El` tree, prepares GPU buffers, and renders:
21//!
22//! ```ignore
23//! use damascene_core::prelude::*;
24//! use damascene_wgpu::Runner;
25//!
26//! let mut runner = Runner::new(&device, &queue, surface_format);
27//! runner.set_surface_size(surface_width, surface_height);
28//!
29//! // Per frame:
30//! app.before_build();
31//! let theme = app.theme();
32//! let mut tree = app.build(&damascene_core::BuildCx::new(&theme));
33//! runner.set_hotkeys(app.hotkeys());
34//! runner.set_theme(theme);
35//! runner.prepare(&device, &queue, tree, viewport, scale_factor);
36//! runner.render(&device, &mut encoder, target_texture, target_view, None, load_op);
37//! ```
38//!
39//! `prepare` is split from `render`/`draw` so all `queue.write_buffer`
40//! calls and atlas uploads happen before render-pass recording, matching
41//! `wgpu`'s expected order. Coordinates passed to pointer methods are
42//! logical pixels; render targets are physical pixels, so pass the host
43//! scale factor to [`Runner::prepare`].
44//!
45//! Use [`Runner::render`] when Damascene should own pass boundaries. This is
46//! required for backdrop-sampling custom shaders. Use [`Runner::draw`]
47//! only when you are already inside a host-owned pass and do not need
48//! backdrop sampling.
49//!
50//! # Custom shaders
51//!
52//! Call [`Runner::register_shader`] with a name and WGSL source. The
53//! shader's vertex/fragment must use the shared instance layout — see
54//! `shaders/rounded_rect.wgsl` (in damascene-core) for the canonical
55//! example. Bind the shader at a node via
56//! `El::shader(ShaderBinding::custom(name).with(...))`. Per-instance
57//! uniforms map to three generic `vec4` slots:
58//!
59//! | Uniform key | Slot (`@location`) | Accepted types |
60//! |---|---|---|
61//! | `vec_a` | 2 | `Color` (rgba 0..1) or `Vec4` |
62//! | `vec_b` | 3 | `Color` or `Vec4` |
63//! | `vec_c` | 4 | `Vec4` (or fall back to scalar `f32` packed in `.x`) |
64//!
65//! Stock `rounded_rect` reuses the same layout but reads its own named
66//! uniforms (`fill`, `stroke`, `stroke_width`, `radius`, `shadow`).
67
68mod icon;
69mod image;
70mod instance;
71mod msaa;
72mod pipeline;
73mod scene;
74mod surface;
75mod text;
76
77pub use crate::msaa::MsaaTarget;
78pub use crate::surface::{StreamingTexture, WgpuAppTexture, app_texture};
79pub use crate::text::SharedText;
80
81use std::collections::{HashMap, HashSet};
82// `web_time::Instant` is API-identical to `std::time::Instant` on
83// native and uses `performance.now()` on wasm32 — std's `Instant::now()`
84// panics in the browser because there is no monotonic clock there.
85use web_time::Instant;
86
87use wgpu::util::DeviceExt;
88
89use damascene_core::event::{KeyChord, KeyModifiers, LogicalKey, PhysicalKey, Pointer, UiEvent};
90use damascene_core::ir::TextAnchor;
91use damascene_core::paint::{IconRunKind, PhysicalScissor, QuadInstance};
92use damascene_core::runtime::{RecordedPaint, RunnerCore, TextRecorder};
93use damascene_core::shader::{ShaderHandle, StockShader, stock_wgsl};
94use damascene_core::state::{AnimationMode, UiState};
95use damascene_core::text::atlas::RunStyle;
96use damascene_core::theme::Theme;
97use damascene_core::tree::{Color, El, Rect, TextWrap};
98use damascene_core::vector::IconMaterial;
99
100pub use damascene_core::paint::PaintItem;
101pub use damascene_core::runtime::{LayoutPrepared, PointerMove, PrepareResult, PrepareTimings};
102
103use crate::icon::IconPaint;
104use crate::image::ImagePaint;
105use crate::instance::set_scissor;
106use crate::pipeline::{FrameUniforms, build_quad_pipeline};
107use crate::scene::Scene3DPaint;
108use crate::surface::SurfacePaint;
109use crate::text::TextPaint;
110
111/// Initial size for the dynamic instance buffer (grows as needed).
112const INITIAL_INSTANCE_CAPACITY: usize = 256;
113
114/// Adapter-derived capabilities the [`Runner`] adapts its pipelines to.
115///
116/// Defaults to everything supported — correct for native Vulkan/Metal/DX
117/// adapters. Hosts that can land on GL or browser adapters should derive
118/// the real values with [`RunnerCaps::from_adapter`] and build the runner
119/// via [`Runner::with_caps`].
120#[derive(Clone, Copy, Debug)]
121pub struct RunnerCaps {
122    /// Whether the adapter supports per-sample MSAA shading
123    /// (`DownlevelFlags::MULTISAMPLED_SHADING`). When `false`, every
124    /// pipeline (stock and later-registered custom) has
125    /// `@interpolate(perspective, sample)` rewritten to
126    /// `@interpolate(perspective)` before WGSL compilation. The shader
127    /// then interpolates at pixel centre instead of per MSAA sample —
128    /// MSAA coverage still works at `sample_count > 1`; only the
129    /// per-sub-sample brightness pass is skipped, slightly thickening
130    /// the AA band on curved SDF edges.
131    pub per_sample_shading: bool,
132    /// Whether the backend can read a scene depth *attachment* back for
133    /// `Scene3D` label occlusion. Must be `false` on GL backends
134    /// (WebGL2): naga's GLSL target can't `textureLoad` depth textures
135    /// (so building the resolve pipeline panics the device), and GLES 3.0
136    /// can't create multisampled depth *textures* at all. When `false`,
137    /// occlusion still works — the capture re-renders the scene's meshes
138    /// with a fragment stage that packs depth into an RGBA8 colour target
139    /// instead of resolving the depth attachment. Costs one extra
140    /// mesh-only pass per camera-pose change on those backends.
141    pub depth_readback: bool,
142}
143
144impl Default for RunnerCaps {
145    fn default() -> Self {
146        Self {
147            per_sample_shading: true,
148            depth_readback: true,
149        }
150    }
151}
152
153impl RunnerCaps {
154    /// Derive the caps from the adapter the host actually got.
155    ///
156    /// GL is treated as unsupported across the board regardless of the
157    /// reported downlevel flags: Chrome's SwiftShader WebGL2 fallback
158    /// reports `MULTISAMPLED_SHADING` through wgpu, but the GLSL ES
159    /// target still rejects the sample interpolation qualifier (and can
160    /// never `textureLoad` a depth texture). WebGPU/native keep trusting
161    /// the adapter flags.
162    pub fn from_adapter(adapter: &wgpu::Adapter) -> Self {
163        let gl = adapter.get_info().backend == wgpu::Backend::Gl;
164        Self {
165            per_sample_shading: !gl
166                && adapter
167                    .get_downlevel_capabilities()
168                    .flags
169                    .contains(wgpu::DownlevelFlags::MULTISAMPLED_SHADING),
170            depth_readback: !gl,
171        }
172    }
173}
174
175/// Wgpu runtime owned by the host. One instance per surface/format.
176///
177/// All backend-agnostic state — interaction state, paint-stream scratch,
178/// per-stage layout/animation hooks — lives in `core: RunnerCore` and
179/// is shared with the vulkano backend. The fields below are wgpu-specific
180/// resources only.
181pub struct Runner {
182    target_format: wgpu::TextureFormat,
183    sample_count: u32,
184    /// [`RunnerCaps::per_sample_shading`], kept past construction because
185    /// later-registered custom shaders go through [`build_quad_pipeline`]
186    /// too. (`depth_readback` lives on in [`Scene3DPaint`].)
187    per_sample_shading: bool,
188
189    // Shared resources.
190    pipeline_layout: wgpu::PipelineLayout,
191    /// Pipeline layout for `samples_backdrop` custom shaders — adds
192    /// `@group(1)` for the snapshot texture + sampler.
193    backdrop_pipeline_layout: wgpu::PipelineLayout,
194    quad_bind_group: wgpu::BindGroup,
195    backdrop_bind_layout: wgpu::BindGroupLayout,
196    backdrop_sampler: wgpu::Sampler,
197    frame_buf: wgpu::Buffer,
198    quad_vbo: wgpu::Buffer,
199    instance_buf: wgpu::Buffer,
200    instance_capacity: usize,
201
202    // One pipeline per registered shader (stock + custom).
203    pipelines: HashMap<ShaderHandle, wgpu::RenderPipeline>,
204    // Custom shader names registered with `samples_backdrop=true`. The
205    // paint scheduler queries this to insert pass boundaries before the
206    // first backdrop-sampling draw.
207    backdrop_shaders: HashSet<&'static str>,
208    // Custom shader names registered with `samples_time=true`. Mirrors
209    // `backdrop_shaders` but feeds `prepare_layout`'s continuous-redraw
210    // scan instead of the paint scheduler.
211    time_shaders: HashSet<&'static str>,
212    // Retained WGSL source per registered custom shader, keyed by name
213    // (re-registering replaces the entry). `register_shader_with` builds
214    // the pipeline *and* stashes the source here so
215    // [`Self::set_target_format`] can rebuild every custom pipeline against
216    // the new swapchain format. The bool is the `samples_backdrop` flag,
217    // which selects the same pipeline layout the original registration used.
218    custom_shaders: HashMap<&'static str, (String, bool)>,
219
220    // stock::text resources — atlas, page textures, glyph instances.
221    text_paint: TextPaint,
222    // stock::icon_line resources — vector icon stroke instances.
223    icon_paint: IconPaint,
224    // stock::image resources — per-image texture cache + instance buf.
225    image_paint: ImagePaint,
226    surface_paint: SurfacePaint,
227    // stock::scene resources — geometry buffer cache, per-node offscreen
228    // targets, scene pipelines. Renders DrawOp::Scene3D offscreen and
229    // composites the resolved texture through the surface path.
230    scene_paint: Scene3DPaint,
231
232    /// Lazily-allocated snapshot of the color target, sized to match
233    /// the current target on each `render()`. Backdrop-sampling
234    /// shaders read this via `@group(1)` after Pass A.
235    snapshot: Option<SnapshotTexture>,
236    /// Bind group binding the snapshot view + sampler. Rebuilt each
237    /// time the snapshot texture is reallocated.
238    backdrop_bind_group: Option<wgpu::BindGroup>,
239
240    /// Wall-clock origin for the `time` field in `FrameUniforms`.
241    /// `prepare()` writes `(now - start_time).as_secs_f32()`.
242    start_time: Instant,
243
244    /// Output white-level scale written into `FrameUniforms.white_scale`.
245    /// 1.0 whenever the surface puts reference white at signal 1.0 —
246    /// 8-bit sRGB, and Wayland's anchored parametric ext-linear float
247    /// swapchain. A host whose surface reads as genuine Windows scRGB
248    /// (signal 1.0 = 80 cd/m² absolute) sets 203/80 so UI white lands
249    /// at the encoding's assumed reference white. See
250    /// [`Self::set_white_scale`] and docs/COLOR_MANAGEMENT.md.
251    white_scale: f32,
252    /// Output luminance headroom (`target_max / reference`, 1.0 on SDR)
253    /// and reference white in cd/m², written into
254    /// `FrameUniforms.headroom/ref_nits` and (headroom) mirrored into
255    /// the image paint for the per-image HDR remaster. See
256    /// [`Self::set_output_luminance`].
257    headroom: f32,
258    ref_nits: f32,
259
260    // Backend-agnostic state shared with damascene-vulkano: interaction
261    // state, paint-stream scratch (quad_scratch / runs / paint_items),
262    // viewport_px, last_tree, the 13 input plumbing methods.
263    core: RunnerCore,
264}
265
266struct SnapshotTexture {
267    texture: wgpu::Texture,
268    extent: (u32, u32),
269}
270
271struct PaintRecorder<'a> {
272    text: &'a mut TextPaint,
273    icons: &'a mut IconPaint,
274    images: &'a mut ImagePaint,
275    surfaces: &'a mut SurfacePaint,
276    scenes: &'a mut Scene3DPaint,
277    device: &'a wgpu::Device,
278    queue: &'a wgpu::Queue,
279}
280
281impl TextRecorder for PaintRecorder<'_> {
282    fn record(
283        &mut self,
284        rect: Rect,
285        scissor: Option<PhysicalScissor>,
286        style: &damascene_core::text::atlas::RunStyle,
287        text: &str,
288        size: f32,
289        line_height: f32,
290        wrap: TextWrap,
291        anchor: TextAnchor,
292        scale_factor: f32,
293    ) -> std::ops::Range<usize> {
294        self.text.record(
295            rect,
296            scissor,
297            style,
298            text,
299            size,
300            line_height,
301            wrap,
302            anchor,
303            scale_factor,
304        )
305    }
306
307    fn record_runs(
308        &mut self,
309        rect: Rect,
310        scissor: Option<PhysicalScissor>,
311        runs: &[(String, RunStyle)],
312        size: f32,
313        line_height: f32,
314        wrap: TextWrap,
315        anchor: TextAnchor,
316        scale_factor: f32,
317    ) -> std::ops::Range<usize> {
318        self.text.record_runs(
319            rect,
320            scissor,
321            runs,
322            size,
323            line_height,
324            wrap,
325            anchor,
326            scale_factor,
327        )
328    }
329
330    fn record_icon(
331        &mut self,
332        rect: Rect,
333        scissor: Option<PhysicalScissor>,
334        source: &damascene_core::icons::svg::IconSource,
335        color: Color,
336        _size: f32,
337        stroke_width: f32,
338        _scale_factor: f32,
339    ) -> RecordedPaint {
340        RecordedPaint::Icon(
341            self.icons
342                .record(rect, scissor, source, color, stroke_width),
343        )
344    }
345
346    fn record_image(
347        &mut self,
348        rect: Rect,
349        scissor: Option<PhysicalScissor>,
350        image: &damascene_core::image::Image,
351        tint: Option<Color>,
352        radius: damascene_core::tree::Corners,
353        _fit: damascene_core::image::ImageFit,
354        range_limit: damascene_core::image::DynamicRangeLimit,
355        _scale_factor: f32,
356    ) -> std::ops::Range<usize> {
357        self.images.record(
358            self.device,
359            self.queue,
360            rect,
361            scissor,
362            image,
363            tint,
364            radius,
365            range_limit,
366        )
367    }
368
369    fn record_app_texture(
370        &mut self,
371        rect: Rect,
372        scissor: Option<PhysicalScissor>,
373        texture: &damascene_core::surface::AppTexture,
374        alpha: damascene_core::surface::SurfaceAlpha,
375        transform: damascene_core::affine::Affine2,
376        _scale_factor: f32,
377    ) -> std::ops::Range<usize> {
378        self.surfaces
379            .record(self.device, rect, scissor, texture, alpha, transform)
380    }
381
382    fn record_vector(
383        &mut self,
384        rect: Rect,
385        scissor: Option<PhysicalScissor>,
386        asset: &damascene_core::vector::VectorAsset,
387        render_mode: damascene_core::vector::VectorRenderMode,
388        _scale_factor: f32,
389    ) -> std::ops::Range<usize> {
390        self.icons.record_vector(rect, scissor, asset, render_mode)
391    }
392
393    fn record_scene3d(
394        &mut self,
395        rect: Rect,
396        scissor: Option<PhysicalScissor>,
397        id: &str,
398        scene: &std::sync::Arc<damascene_core::scene::Scene3DData>,
399        scale_factor: f32,
400    ) -> std::ops::Range<usize> {
401        self.scenes
402            .record(self.device, rect, scissor, id, scene, scale_factor)
403    }
404}
405
406/// Build the four stock rect-shaped quad pipelines (rounded_rect, spinner,
407/// skeleton, progress_indeterminate) into `pipelines`, replacing any
408/// existing entries. Shared by [`Runner::with_caps`] and
409/// [`Runner::set_target_format`] so the catalog stays a single source of
410/// truth — only `target_format` varies across the two call sites.
411fn build_stock_quad_pipelines(
412    pipelines: &mut HashMap<ShaderHandle, wgpu::RenderPipeline>,
413    device: &wgpu::Device,
414    layout: &wgpu::PipelineLayout,
415    target_format: wgpu::TextureFormat,
416    sample_count: u32,
417    per_sample_shading: bool,
418) {
419    for (handle, label, wgsl) in [
420        (
421            StockShader::RoundedRect,
422            "stock::rounded_rect",
423            stock_wgsl::ROUNDED_RECT,
424        ),
425        (StockShader::Spinner, "stock::spinner", stock_wgsl::SPINNER),
426        (
427            StockShader::Skeleton,
428            "stock::skeleton",
429            stock_wgsl::SKELETON,
430        ),
431        (
432            StockShader::ProgressIndeterminate,
433            "stock::progress_indeterminate",
434            stock_wgsl::PROGRESS_INDETERMINATE,
435        ),
436    ] {
437        let pipeline = build_quad_pipeline(
438            device,
439            layout,
440            target_format,
441            sample_count,
442            label,
443            wgsl,
444            per_sample_shading,
445        );
446        pipelines.insert(ShaderHandle::Stock(handle), pipeline);
447    }
448}
449
450impl Runner {
451    /// Create a runner for the given target color format. The host
452    /// passes its swapchain/render-target format here so pipelines and
453    /// the glyph atlas are built compatible.
454    pub fn new(
455        device: &wgpu::Device,
456        queue: &wgpu::Queue,
457        target_format: wgpu::TextureFormat,
458    ) -> Self {
459        Self::with_sample_count(device, queue, target_format, 1)
460    }
461
462    /// Like [`Self::new`], but builds all pipelines with `sample_count`
463    /// MSAA samples. The host must provide a matching multisampled
464    /// render target and a single-sample resolve target. `sample_count`
465    /// of 1 is the non-MSAA default.
466    ///
467    /// Defaults to [`RunnerCaps::default`] (everything supported) —
468    /// appropriate for native adapters. Hosts that can land on GL or
469    /// browser adapters must instead route through [`Self::with_caps`]
470    /// with [`RunnerCaps::from_adapter`], otherwise stock pipelines fail
471    /// naga validation on shader-module creation.
472    pub fn with_sample_count(
473        device: &wgpu::Device,
474        queue: &wgpu::Queue,
475        target_format: wgpu::TextureFormat,
476        sample_count: u32,
477    ) -> Self {
478        Self::with_caps(
479            device,
480            queue,
481            target_format,
482            sample_count,
483            RunnerCaps::default(),
484        )
485    }
486
487    /// Like [`Self::with_sample_count`], but with the adapter caps
488    /// supplied explicitly — see [`RunnerCaps`] for what each cap gates:
489    ///
490    /// ```ignore
491    /// Runner::with_caps(&device, &queue, format, sample_count,
492    ///                   RunnerCaps::from_adapter(&adapter))
493    /// ```
494    pub fn with_caps(
495        device: &wgpu::Device,
496        queue: &wgpu::Queue,
497        target_format: wgpu::TextureFormat,
498        sample_count: u32,
499        caps: RunnerCaps,
500    ) -> Self {
501        Self::with_caps_inner(device, queue, target_format, sample_count, caps, None)
502    }
503
504    /// Like [`Self::with_caps`], but attached to an existing
505    /// [`SharedText`] pool instead of creating a private one (issue
506    /// #94). Every runner attached to the same pool shares one font
507    /// system, one shaping cache, and one set of glyph/MSDF atlas
508    /// pages on the GPU — a multi-window host pays glyph
509    /// rasterization, warm-up, and atlas VRAM once per *device*
510    /// instead of once per window:
511    ///
512    /// ```ignore
513    /// let text = SharedText::new(&device);
514    /// text.warm_default_glyphs(); // once, off the open path
515    /// let runner_a = Runner::with_shared_text(&device, &queue, fmt_a, 1, caps, &text);
516    /// let runner_b = Runner::with_shared_text(&device, &queue, fmt_b, 1, caps, &text);
517    /// ```
518    ///
519    /// The pool is device-scoped and format/sample-count independent —
520    /// runners with different swapchain formats or MSAA settings can
521    /// share one pool. An existing runner's pool is available via
522    /// [`Self::shared_text`]. The pool must have been created on the
523    /// same `wgpu::Device`.
524    pub fn with_shared_text(
525        device: &wgpu::Device,
526        queue: &wgpu::Queue,
527        target_format: wgpu::TextureFormat,
528        sample_count: u32,
529        caps: RunnerCaps,
530        shared: &SharedText,
531    ) -> Self {
532        Self::with_caps_inner(
533            device,
534            queue,
535            target_format,
536            sample_count,
537            caps,
538            Some(shared.clone()),
539        )
540    }
541
542    fn with_caps_inner(
543        device: &wgpu::Device,
544        _queue: &wgpu::Queue,
545        target_format: wgpu::TextureFormat,
546        sample_count: u32,
547        caps: RunnerCaps,
548        shared_text: Option<SharedText>,
549    ) -> Self {
550        let RunnerCaps {
551            per_sample_shading,
552            depth_readback,
553        } = caps;
554        // ---- Shared resources ----
555        let frame_buf = device.create_buffer(&wgpu::BufferDescriptor {
556            label: Some("damascene_wgpu::frame_uniforms"),
557            size: std::mem::size_of::<FrameUniforms>() as u64,
558            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
559            mapped_at_creation: false,
560        });
561
562        let frame_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
563            label: Some("damascene_wgpu::frame_bind_layout"),
564            entries: &[wgpu::BindGroupLayoutEntry {
565                binding: 0,
566                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
567                ty: wgpu::BindingType::Buffer {
568                    ty: wgpu::BufferBindingType::Uniform,
569                    has_dynamic_offset: false,
570                    min_binding_size: None,
571                },
572                count: None,
573            }],
574        });
575
576        let quad_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
577            label: Some("damascene_wgpu::frame_bind_group"),
578            layout: &frame_bind_layout,
579            entries: &[wgpu::BindGroupEntry {
580                binding: 0,
581                resource: frame_buf.as_entire_binding(),
582            }],
583        });
584
585        let quad_vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
586            label: Some("damascene_wgpu::quad_vbo"),
587            // Triangle strip: 4 corners, uv 0..1.
588            contents: bytemuck::cast_slice::<f32, u8>(&[0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
589            usage: wgpu::BufferUsages::VERTEX,
590        });
591
592        let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
593            label: Some("damascene_wgpu::instance_buf"),
594            size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<QuadInstance>()) as u64,
595            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
596            mapped_at_creation: false,
597        });
598
599        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
600            label: Some("damascene_wgpu::pipeline_layout"),
601            bind_group_layouts: &[Some(&frame_bind_layout)],
602            immediate_size: 0,
603        });
604
605        // ---- Backdrop sampling resources ----
606        //
607        // Custom shaders that opt into backdrop sampling (registered
608        // via `register_shader_with(..samples_backdrop=true)`) get a
609        // pipeline layout with `@group(1)` for the snapshot texture
610        // and sampler. The bind group is rebuilt whenever the
611        // snapshot is (re)allocated.
612        let backdrop_bind_layout =
613            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
614                label: Some("damascene_wgpu::backdrop_bind_layout"),
615                entries: &[
616                    wgpu::BindGroupLayoutEntry {
617                        binding: 0,
618                        visibility: wgpu::ShaderStages::FRAGMENT,
619                        ty: wgpu::BindingType::Texture {
620                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
621                            view_dimension: wgpu::TextureViewDimension::D2,
622                            multisampled: false,
623                        },
624                        count: None,
625                    },
626                    wgpu::BindGroupLayoutEntry {
627                        binding: 1,
628                        visibility: wgpu::ShaderStages::FRAGMENT,
629                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
630                        count: None,
631                    },
632                ],
633            });
634        let backdrop_pipeline_layout =
635            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
636                label: Some("damascene_wgpu::backdrop_pipeline_layout"),
637                bind_group_layouts: &[Some(&frame_bind_layout), Some(&backdrop_bind_layout)],
638                immediate_size: 0,
639            });
640        let backdrop_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
641            label: Some("damascene_wgpu::backdrop_sampler"),
642            address_mode_u: wgpu::AddressMode::ClampToEdge,
643            address_mode_v: wgpu::AddressMode::ClampToEdge,
644            address_mode_w: wgpu::AddressMode::ClampToEdge,
645            mag_filter: wgpu::FilterMode::Linear,
646            min_filter: wgpu::FilterMode::Linear,
647            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
648            ..Default::default()
649        });
650
651        // Build stock rect-shaped pipelines up-front; custom shaders are
652        // added on demand by the host.
653        let mut pipelines = HashMap::new();
654        build_stock_quad_pipelines(
655            &mut pipelines,
656            device,
657            &pipeline_layout,
658            target_format,
659            sample_count,
660            per_sample_shading,
661        );
662
663        // Text pipeline + atlas (replaces glyphon). Attaches to the
664        // caller's shared pool when given one; otherwise the runner
665        // gets a private pool (single-window behavior, unchanged).
666        let text_paint = match shared_text {
667            Some(shared) => TextPaint::with_shared(
668                device,
669                target_format,
670                sample_count,
671                &frame_bind_layout,
672                shared,
673            ),
674            None => TextPaint::new(device, target_format, sample_count, &frame_bind_layout),
675        };
676        let icon_paint = IconPaint::new(device, target_format, sample_count, &frame_bind_layout);
677        let image_paint = ImagePaint::new(device, target_format, sample_count, &frame_bind_layout);
678        let surface_paint =
679            SurfacePaint::new(device, target_format, sample_count, &frame_bind_layout);
680        let scene_paint = Scene3DPaint::new(
681            device,
682            target_format,
683            sample_count,
684            &frame_bind_layout,
685            damascene_core::paint::DEFAULT_WORKING_COLOR_SPACE,
686            depth_readback,
687        );
688
689        let mut core = RunnerCore::new();
690        core.quad_scratch = Vec::with_capacity(INITIAL_INSTANCE_CAPACITY);
691
692        Self {
693            target_format,
694            sample_count,
695            per_sample_shading,
696            pipeline_layout,
697            backdrop_pipeline_layout,
698            quad_bind_group,
699            backdrop_bind_layout,
700            backdrop_sampler,
701            frame_buf,
702            quad_vbo,
703            instance_buf,
704            instance_capacity: INITIAL_INSTANCE_CAPACITY,
705            pipelines,
706            backdrop_shaders: HashSet::new(),
707            time_shaders: HashSet::new(),
708            custom_shaders: HashMap::new(),
709            text_paint,
710            icon_paint,
711            image_paint,
712            surface_paint,
713            scene_paint,
714            snapshot: None,
715            backdrop_bind_group: None,
716            start_time: Instant::now(),
717            white_scale: 1.0,
718            headroom: 1.0,
719            ref_nits: damascene_core::color::BT2408_REFERENCE_WHITE_NITS,
720            core,
721        }
722    }
723
724    /// Tell the runner the swapchain texture size in physical pixels.
725    /// Call this once after `surface.configure(...)` and again on every
726    /// `WindowEvent::Resized`. The runner uses this as the canonical
727    /// `viewport_px` for scissor math; without it, the value is derived
728    /// from `viewport.w * scale_factor`, which can drift by one pixel
729    /// when `scale_factor` is fractional and trip wgpu's
730    /// `set_scissor_rect` validation.
731    pub fn set_surface_size(&mut self, width: u32, height: u32) {
732        self.core.set_surface_size(width, height);
733    }
734
735    /// Set the color space the renderer composites in. Hosts call this
736    /// once after negotiating a surface format with the display server
737    /// (see `damascene-winit-wgpu`) and before the first frame. Updates the
738    /// shared quad path (via `RunnerCore`) and this backend's text /
739    /// icon / image color recorders so every color crosses the working-
740    /// space boundary consistently.
741    ///
742    /// The working space must match how the swapchain interprets the
743    /// pixels the renderer writes: `SRGB_LINEAR` for an `*_unorm_srgb`
744    /// surface (the default), `SCRGB_LINEAR` / `DISPLAY_P3_LINEAR` for
745    /// an `Rgba16Float` surface, etc.
746    pub fn set_working_color_space(&mut self, space: damascene_core::color::ColorSpace) {
747        self.core.set_working_color_space(space);
748        self.text_paint.set_working_color_space(space);
749        self.icon_paint.set_working_color_space(space);
750        self.image_paint.set_working_color_space(space);
751        self.scene_paint.set_working_color_space(space);
752    }
753
754    /// The color space the renderer currently composites in.
755    pub fn working_color_space(&self) -> damascene_core::color::ColorSpace {
756        self.core.working_color_space()
757    }
758
759    /// Rebuild every swapchain-format-bound render pipeline for a new
760    /// surface format, in place, preserving all other runner state.
761    ///
762    /// The `damascene-winit-wgpu` host calls this on **live color
763    /// renegotiation** — when the display server hands back a different
764    /// surface format than the one the runner was built with (e.g.
765    /// `Bgra8UnormSrgb` ↔ `Rgba16Float` when HDR turns on or off). The
766    /// swapchain format is baked into every pipeline's `ColorTargetState`,
767    /// so those pipelines must be recreated; everything else can stay.
768    ///
769    /// **What survives:** all interaction state in `RunnerCore` (hover,
770    /// focus, press, selection, scroll, hotkeys, the laid-out tree
771    /// snapshot), the glyph + icon MSDF atlases and their GPU page
772    /// textures, the per-image and app-texture/surface bind-group caches,
773    /// the scene geometry caches and per-node offscreen targets, and every
774    /// instance/uniform/vertex buffer. No atlas re-rasterization, no
775    /// texture re-upload, no layout recompute.
776    ///
777    /// **What's rebuilt:** the four stock quad pipelines (rounded_rect,
778    /// spinner, skeleton, progress_indeterminate), every retained custom
779    /// shader pipeline, and the swapchain-bound pipelines inside each paint
780    /// module (text color/MSDF/highlight, icon flat/relief/glass/MSDF,
781    /// image, surface premul/straight/opaque, and the scene composite —
782    /// the scene's offscreen point/line/mesh + occlusion pipelines render
783    /// to fixed formats and are left alone). The backdrop snapshot texture
784    /// is dropped so it reallocates in the new format on the next
785    /// backdrop-sampling frame.
786    ///
787    /// Early-returns when `format` already matches the current target.
788    /// `sample_count` and `per_sample_shading` are unaffected.
789    pub fn set_target_format(&mut self, device: &wgpu::Device, format: wgpu::TextureFormat) {
790        if format == self.target_format {
791            return;
792        }
793        self.target_format = format;
794
795        // Stock quad pipelines (replaces the four entries in place).
796        build_stock_quad_pipelines(
797            &mut self.pipelines,
798            device,
799            &self.pipeline_layout,
800            format,
801            self.sample_count,
802            self.per_sample_shading,
803        );
804
805        // Retained custom shader pipelines. Same layout selection as
806        // `register_shader_with`: backdrop-sampling shaders bind `@group(1)`.
807        for (name, (wgsl, samples_backdrop)) in &self.custom_shaders {
808            let layout = if *samples_backdrop {
809                &self.backdrop_pipeline_layout
810            } else {
811                &self.pipeline_layout
812            };
813            let pipeline = build_quad_pipeline(
814                device,
815                layout,
816                format,
817                self.sample_count,
818                &format!("custom::{name}"),
819                wgsl,
820                self.per_sample_shading,
821            );
822            self.pipelines.insert(ShaderHandle::Custom(name), pipeline);
823        }
824
825        // Per-paint-module swapchain-bound pipelines.
826        self.text_paint.set_target_format(device, format);
827        self.icon_paint.set_target_format(device, format);
828        self.image_paint.set_target_format(device, format);
829        self.surface_paint.set_target_format(device, format);
830        self.scene_paint.set_target_format(device, format);
831
832        // The backdrop snapshot texture is created in the target format
833        // (see `ensure_snapshot`); drop it so the next backdrop-sampling
834        // frame lazily reallocates it in the new format. The bind group
835        // referencing it goes too — it's rebuilt alongside the texture.
836        self.snapshot = None;
837        self.backdrop_bind_group = None;
838    }
839
840    /// Set the output white-level scale (default 1.0). Leave at 1.0
841    /// whenever the surface puts reference white at signal 1.0: 8-bit
842    /// sRGB by definition, and Wayland float swapchains tagged as
843    /// parametric ext-linear (the WSI default — the compositor anchors
844    /// signal 1.0 to the output reference; scaling on top double-lifts
845    /// ~2.5×). Pass
846    /// [`damascene_core::color::WINDOWS_SCRGB_WHITE_SCALE`] only when
847    /// the surface genuinely reads as Windows scRGB — signal 1.0 =
848    /// 80 cd/m² *absolute*, assumed reference white at 2.5375 (203
849    /// cd/m², BT.2408) — so SDR-referred UI white lands at the
850    /// reference level instead of 80 nits.
851    pub fn set_white_scale(&mut self, scale: f32) {
852        self.white_scale = scale;
853    }
854
855    /// Set the output's luminance frame: `headroom` = usable range in
856    /// multiples of reference white (`target_max / reference`; 1.0 on
857    /// SDR — the default — or `f32::INFINITY` when the output declared
858    /// no maximum) and `reference_nits` = the output's reference white
859    /// in cd/m² (default 203, BT.2408). Feeds
860    /// `FrameUniforms.headroom/ref_nits` and the per-image HDR
861    /// remaster: image draws whose measured content peak exceeds their
862    /// [`damascene_core::image::DynamicRangeLimit`] resolved against
863    /// this headroom are rolled off (BT.2390) to fit. Hosts re-call
864    /// this whenever the output's preferred description changes.
865    pub fn set_output_luminance(&mut self, headroom: f32, reference_nits: f32) {
866        self.headroom = headroom;
867        self.ref_nits = reference_nits;
868        self.image_paint.set_headroom(headroom);
869    }
870
871    /// Set the theme used to resolve implicit widget surfaces to shaders.
872    /// Pre-rasterize printable ASCII for the bundled default faces
873    /// (Inter Variable + JetBrains Mono Variable). Pays the ~40ms
874    /// one-time MSDF-generation cost up-front so the first frame that
875    /// introduces each character doesn't take a 20-30ms paint hit.
876    /// Hosts that interactively render UI text (the showcase, custom
877    /// apps, etc.) should call this once after constructing the
878    /// `Runner` and before the first frame; headless fixtures that
879    /// render only static content can skip it. MSDF keys are
880    /// size-independent so each character is rasterized exactly once
881    /// and reused for every size + weight afterwards.
882    pub fn warm_default_glyphs(&mut self) {
883        let start = std::time::Instant::now();
884        self.text_paint.warm_default_glyphs();
885        // ~40ms optimized; ~19s at opt-level 0 (MSDF generation is the
886        // cost). A debug build paying the cliff almost always means the
887        // consumer workspace is missing the dev-profile overrides — say
888        // so instead of reading as "damascene takes 20s to start".
889        let elapsed = start.elapsed();
890        if elapsed > std::time::Duration::from_secs(2) {
891            log::warn!(
892                "damascene-wgpu: warm_default_glyphs took {elapsed:.1?} — unoptimized MSDF                  generation. Add the [profile.dev.package] opt-level overrides from                  damascene's README to your workspace root Cargo.toml (and don't call                  this inside a Wayland dispatch callback in debug builds)."
893            );
894        }
895    }
896
897    /// Pre-rasterize a chosen set of `(family, char)` glyphs — the
898    /// app-selectable counterpart to [`Self::warm_default_glyphs`], for
899    /// fonts you registered yourself or glyph sets beyond printable
900    /// ASCII. See [`SharedText::warm_glyphs`].
901    pub fn warm_glyphs(&mut self, families: &[damascene_core::tree::FontFamily], chars: &[char]) {
902        self.text_paint.warm_glyphs(families, chars);
903    }
904
905    /// Serialize the resident outline-glyph atlas into a portable
906    /// snapshot blob (keyed by font content hash). Persist it and reload
907    /// with [`Self::import_msdf_snapshot`] to skip regenerating those
908    /// glyphs on a later run — the app-driven equivalent of the built-in
909    /// `prebaked-default-fonts` bake. See [`SharedText::export_msdf_snapshot`].
910    pub fn export_msdf_snapshot(&self) -> Vec<u8> {
911        self.text_paint.export_msdf_snapshot()
912    }
913
914    /// Load a snapshot from [`Self::export_msdf_snapshot`], resolving
915    /// fonts by content hash against those currently loaded. Returns the
916    /// glyph count loaded, or an error if the blob is stale/unreadable
917    /// (warm live in that case). See [`SharedText::import_msdf_snapshot`].
918    pub fn import_msdf_snapshot(
919        &mut self,
920        bytes: &[u8],
921    ) -> Result<usize, damascene_core::text::msdf_snapshot::SnapshotError> {
922        self.text_paint.import_msdf_snapshot(bytes)
923    }
924
925    /// The [`SharedText`] pool this runner records text into. Hand it
926    /// to [`Self::with_shared_text`] when constructing further runners
927    /// on the same device so they share fonts, shaping, and atlases —
928    /// works whether this runner was built with a shared pool or owns
929    /// a private one.
930    pub fn shared_text(&self) -> SharedText {
931        self.text_paint.shared().clone()
932    }
933
934    pub fn set_theme(&mut self, theme: Theme) {
935        self.icon_paint.set_material(theme.icon_material());
936        self.core.set_theme(theme);
937    }
938
939    pub fn theme(&self) -> &Theme {
940        self.core.theme()
941    }
942
943    /// Select the stock material used by the vector-icon painter.
944    /// Prefer [`Theme::with_icon_material`] for app-level routing; this
945    /// remains useful for low-level render fixtures.
946    pub fn set_icon_material(&mut self, material: IconMaterial) {
947        self.icon_paint.set_material(material);
948    }
949
950    pub fn icon_material(&self) -> IconMaterial {
951        self.icon_paint.material()
952    }
953
954    /// Register a custom shader. `name` is the same string passed to
955    /// `damascene_core::shader::ShaderBinding::custom`; nodes bound to it
956    /// via [`El::shader`](damascene_core::tree::El) paint through this
957    /// pipeline.
958    ///
959    /// The WGSL source must use the shared `(rect, vec_a, vec_b, vec_c)`
960    /// instance layout and the `FrameUniforms` bind group described in
961    /// the module docs. Compilation happens at register time — invalid
962    /// WGSL panics here, not mid-frame.
963    ///
964    /// Re-registering the same name replaces the previous pipeline
965    /// (useful for hot-reload during development).
966    pub fn register_shader(&mut self, device: &wgpu::Device, name: &'static str, wgsl: &str) {
967        self.register_shader_with(device, name, wgsl, false, false);
968    }
969
970    /// Register a custom shader, with opt-in flags for backdrop
971    /// sampling and time-driven motion.
972    ///
973    /// `samples_backdrop=true` schedules the shader's draws into
974    /// Pass B (after a snapshot of Pass A's rendered content) and
975    /// binds the snapshot texture as `@group(2) binding=0`
976    /// (`backdrop_tex`) plus a sampler at `binding=1`
977    /// (`backdrop_smp`). See `docs/SHADER_VISION.md` §"Backdrop
978    /// sampling architecture". Backdrop depth is capped at 1.
979    ///
980    /// `samples_time=true` declares that the shader's output depends
981    /// on `frame.time`. The runtime ORs this into
982    /// [`PrepareResult::needs_redraw`] for any frame that has at
983    /// least one node bound to the shader, so the host idle loop
984    /// keeps ticking without a per-El opt-in. Stock shaders self-
985    /// report through [`damascene_core::shader::StockShader::is_continuous`];
986    /// this flag is the same signal for app-registered WGSL.
987    pub fn register_shader_with(
988        &mut self,
989        device: &wgpu::Device,
990        name: &'static str,
991        wgsl: &str,
992        samples_backdrop: bool,
993        samples_time: bool,
994    ) {
995        let label = format!("custom::{name}");
996        let layout = if samples_backdrop {
997            &self.backdrop_pipeline_layout
998        } else {
999            &self.pipeline_layout
1000        };
1001        let pipeline = build_quad_pipeline(
1002            device,
1003            layout,
1004            self.target_format,
1005            self.sample_count,
1006            &label,
1007            wgsl,
1008            self.per_sample_shading,
1009        );
1010        self.pipelines.insert(ShaderHandle::Custom(name), pipeline);
1011        // Retain the source so the pipeline can be rebuilt against a new
1012        // swapchain format in `set_target_format`. Re-registering replaces
1013        // the prior entry, matching the pipeline-map replacement above.
1014        self.custom_shaders
1015            .insert(name, (wgsl.to_string(), samples_backdrop));
1016        // Introspect the instance-attribute names so this shader's
1017        // uniforms route by WGSL field name with Rust↔WGSL drift
1018        // detection (issue #99). Failure is non-fatal: positional
1019        // vec_a..vec_e routing still works.
1020        match damascene_core::paint::slots::introspect_wgsl(wgsl) {
1021            Ok(map) => self.core.register_shader_slots(name, map),
1022            Err(e) => log::warn!(
1023                "damascene-wgpu: could not introspect shader `{name}` for named uniform \
1024                 routing ({e}); positional vec_a..vec_e routing still applies"
1025            ),
1026        }
1027        if samples_backdrop {
1028            self.backdrop_shaders.insert(name);
1029        } else {
1030            self.backdrop_shaders.remove(name);
1031        }
1032        if samples_time {
1033            self.time_shaders.insert(name);
1034        } else {
1035            self.time_shaders.remove(name);
1036        }
1037    }
1038
1039    /// Borrow the internal [`UiState`] — primarily for headless fixtures
1040    /// that want to look up a node's rect after `prepare` (e.g., to
1041    /// simulate a pointer at a specific button's center).
1042    pub fn ui_state(&self) -> &UiState {
1043        self.core.ui_state()
1044    }
1045
1046    /// One-line diagnostic snapshot of interactive state — passes through
1047    /// to [`UiState::debug_summary`]. Intended for per-frame logging
1048    /// (e.g., `console.log` from the wasm host while debugging hover /
1049    /// animation glitches).
1050    pub fn debug_summary(&self) -> String {
1051        self.core.debug_summary()
1052    }
1053
1054    /// Return the most recently laid-out rectangle for a keyed node.
1055    ///
1056    /// Call after [`Self::prepare`]. This is the host-composition hook:
1057    /// reserve a keyed Damascene element in the UI tree, ask for its rect
1058    /// here, then record host-owned rendering into that region using the
1059    /// same encoder / render flow that surrounds Damascene's pass.
1060    pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
1061        self.core.rect_of_key(key)
1062    }
1063
1064    /// Pointer cursor resolved from the snapshot tree [`Self::prepare`]
1065    /// just stored. Call after `prepare`; paint-only frames keep the
1066    /// previously resolved cursor.
1067    pub fn snapshot_cursor(&self) -> damascene_core::cursor::Cursor {
1068        self.core.snapshot_cursor()
1069    }
1070
1071    /// Lay out the tree, resolve to draw ops, and upload per-frame
1072    /// buffers (quad instances + glyph atlas). Must be called before
1073    /// [`Self::draw`] and outside of any render pass.
1074    ///
1075    /// `viewport` is in **logical** pixels — the units the layout pass
1076    /// works in. `scale_factor` is the HiDPI multiplier (1.0 on a
1077    /// regular display, 2.0 on most modern HiDPI, can be fractional).
1078    /// The host's render-pass target should be sized at physical pixels
1079    /// (`viewport × scale_factor`); the runner maps logical → physical
1080    /// internally so layout, fonts, and SDF math stay device-independent.
1081    ///
1082    /// Takes the tree by value: after layout it becomes the hit-test
1083    /// snapshot directly (no whole-tree clone). Read post-layout state
1084    /// through the runner (e.g. [`Self::snapshot_cursor`]).
1085    pub fn prepare(
1086        &mut self,
1087        device: &wgpu::Device,
1088        queue: &wgpu::Queue,
1089        mut root: El,
1090        viewport: Rect,
1091        scale_factor: f32,
1092    ) -> PrepareResult {
1093        let mut timings = PrepareTimings::default();
1094
1095        // Install any scene depth maps that finished reading back (a frame
1096        // or two late) so this frame's `draw_ops` can occlude scene-anchored
1097        // labels behind geometry. Done before `prepare_layout` runs the
1098        // draw-op pass. Stale maps for scenes that left the tree are GC'd.
1099        let ready_depth = self.scene_paint.collect_depth_maps(device);
1100        if !ready_depth.is_empty() {
1101            let depth_maps = self.core.ui_state.scene_depth_mut();
1102            for (id, map) in ready_depth {
1103                depth_maps.insert(id, map);
1104            }
1105        }
1106        self.core
1107            .ui_state
1108            .scene_depth_mut()
1109            .retain(|id, _| self.scene_paint.has_target(id));
1110
1111        // Layout + state apply + animation tick + draw_ops resolution.
1112        // Writes timings.layout + timings.draw_ops. The closure feeds
1113        // the runtime's continuous-redraw scan: any node bound to a
1114        // shader registered with `samples_time=true` keeps the host
1115        // loop ticking even when no animation is settling.
1116        let time_shaders = &self.time_shaders;
1117        let LayoutPrepared {
1118            ops,
1119            mut needs_redraw,
1120            mut next_layout_redraw_in,
1121            next_paint_redraw_in,
1122        } =
1123            self.core
1124                .prepare_layout(&mut root, viewport, scale_factor, &mut timings, |handle| {
1125                    match handle {
1126                        ShaderHandle::Custom(name) => time_shaders.contains(name),
1127                        ShaderHandle::Stock(_) => false,
1128                    }
1129                });
1130
1131        // Paint stream: pack quads, record text, preserve z-order. The
1132        // closure is the wgpu-specific "is this shader registered?"
1133        // query (different pipeline types per backend prevent moving the
1134        // check itself into core).
1135        self.text_paint.frame_begin();
1136        self.icon_paint.frame_begin();
1137        self.image_paint.frame_begin();
1138        self.surface_paint.frame_begin();
1139        self.scene_paint.frame_begin();
1140        let pipelines = &self.pipelines;
1141        let backdrop_shaders = &self.backdrop_shaders;
1142        let mut recorder = PaintRecorder {
1143            text: &mut self.text_paint,
1144            icons: &mut self.icon_paint,
1145            images: &mut self.image_paint,
1146            surfaces: &mut self.surface_paint,
1147            scenes: &mut self.scene_paint,
1148            device,
1149            queue,
1150        };
1151        self.core.prepare_paint(
1152            &ops,
1153            |shader| pipelines.contains_key(shader),
1154            |shader| match shader {
1155                ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
1156                ShaderHandle::Stock(_) => false,
1157            },
1158            &mut recorder,
1159            scale_factor,
1160            &mut timings,
1161        );
1162
1163        // GPU upload — wgpu-specific. Resize the instance buffer if
1164        // needed, then write quad_scratch + frame uniforms + flush text
1165        // atlas dirty regions. Wrapped in its own scope so the
1166        // `prepare::gpu_upload` span doesn't bleed into the subsequent
1167        // `snapshot` call (which carries its own span).
1168        {
1169            damascene_core::profile_span!("prepare::gpu_upload");
1170            let t_paint_end = Instant::now();
1171            if self.core.quad_scratch.len() > self.instance_capacity {
1172                let new_cap = self.core.quad_scratch.len().next_power_of_two();
1173                self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
1174                    label: Some("damascene_wgpu::instance_buf (resized)"),
1175                    size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
1176                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1177                    mapped_at_creation: false,
1178                });
1179                self.instance_capacity = new_cap;
1180            }
1181            if !self.core.quad_scratch.is_empty() {
1182                queue.write_buffer(
1183                    &self.instance_buf,
1184                    0,
1185                    bytemuck::cast_slice(&self.core.quad_scratch),
1186                );
1187            }
1188            self.text_paint.flush(device, queue);
1189            self.icon_paint.flush(device, queue);
1190            self.image_paint.flush(device, queue);
1191            self.surface_paint.flush(device, queue);
1192            self.scene_paint.flush(device, queue);
1193            // Pin time to 0 in Settled mode so headless fixtures rendering
1194            // a time-driven shader (e.g. stock::spinner) stay byte-identical
1195            // run-to-run, the same way `Animation::settle()` makes the
1196            // spring/tween path deterministic for SVG/PNG snapshots.
1197            let time = match self.core.ui_state().animation_mode() {
1198                damascene_core::AnimationMode::Settled => 0.0,
1199                damascene_core::AnimationMode::Live => {
1200                    (Instant::now() - self.start_time).as_secs_f32()
1201                }
1202            };
1203            let frame = FrameUniforms {
1204                viewport: [viewport.w, viewport.h],
1205                time,
1206                scale_factor,
1207                white_scale: self.white_scale,
1208                headroom: self.headroom,
1209                ref_nits: self.ref_nits,
1210                _reserved: 0.0,
1211            };
1212            queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
1213            timings.gpu_upload = Instant::now() - t_paint_end;
1214        }
1215
1216        // Snapshot the laid-out tree for next-frame hit-testing —
1217        // moved, not cloned; the tree is rebuilt next frame anyway.
1218        self.core.snapshot_owned(root, &mut timings);
1219
1220        // Move resolved ops into the core's cache so a subsequent
1221        // paint-only frame can reuse them without re-running layout.
1222        self.core.last_ops = ops;
1223
1224        // Damascene renders lazily, but the label-occlusion depth read-back needs
1225        // a few frames to resolve. Keep frames coming until every labelled
1226        // scene has a depth map matching its current pose — otherwise a
1227        // capture started in `render` would sit unmapped after the camera
1228        // settles and the labels would never appear. Settled + current scenes
1229        // (and label-free ones) report `false`, so lazy idle is preserved.
1230        //
1231        // This must drive `next_layout_redraw_in`, not just `needs_redraw`:
1232        // hosts schedule the next frame off the deadline lanes (the winit
1233        // host ignores `needs_redraw`), and it must be the *layout* lane, not
1234        // the paint lane — the paint-only `repaint` path skips
1235        // `collect_depth_maps`, so only a full `prepare` advances the readback.
1236        if self.scene_paint.occlusion_unsettled() {
1237            needs_redraw = true;
1238            next_layout_redraw_in = Some(std::time::Duration::ZERO);
1239        }
1240
1241        let next_redraw_in = match (next_layout_redraw_in, next_paint_redraw_in) {
1242            (Some(a), Some(b)) => Some(a.min(b)),
1243            (Some(d), None) | (None, Some(d)) => Some(d),
1244            (None, None) => None,
1245        };
1246        PrepareResult {
1247            needs_redraw,
1248            next_redraw_in,
1249            next_layout_redraw_in,
1250            next_paint_redraw_in,
1251            timings,
1252        }
1253    }
1254
1255    /// Paint-only frame: rerun [`RunnerCore::prepare_paint_cached`] +
1256    /// GPU upload + frame-uniform write against the cached ops from
1257    /// the most recent [`Self::prepare`] call. Skips rebuild + layout
1258    /// + draw_ops + snapshot — only `frame.time` advances.
1259    ///
1260    /// Hosts call this when [`PrepareResult::next_paint_redraw_in`]
1261    /// fires (a time-driven shader needs another frame) and no input
1262    /// has been processed since the last full prepare. Input always
1263    /// upgrades to the full `prepare(...)` path.
1264    ///
1265    /// `viewport` and `scale_factor` must match the values passed to
1266    /// the most recent `prepare(...)` — a resize must go through the
1267    /// full layout path. Returns the same shape of [`PrepareResult`]
1268    /// for diagnostic continuity, with both deadlines re-computed
1269    /// from the cached signals: `next_layout_redraw_in` is `None` (we
1270    /// didn't re-evaluate), and `next_paint_redraw_in` is whatever
1271    /// the cached ops still report. The host owns the layout
1272    /// deadline across paint-only frames.
1273    pub fn repaint(
1274        &mut self,
1275        device: &wgpu::Device,
1276        queue: &wgpu::Queue,
1277        viewport: Rect,
1278        scale_factor: f32,
1279    ) -> PrepareResult {
1280        let mut timings = PrepareTimings::default();
1281
1282        self.text_paint.frame_begin();
1283        self.icon_paint.frame_begin();
1284        self.image_paint.frame_begin();
1285        self.surface_paint.frame_begin();
1286        self.scene_paint.frame_begin();
1287        let pipelines = &self.pipelines;
1288        let backdrop_shaders = &self.backdrop_shaders;
1289        let mut recorder = PaintRecorder {
1290            text: &mut self.text_paint,
1291            icons: &mut self.icon_paint,
1292            images: &mut self.image_paint,
1293            surfaces: &mut self.surface_paint,
1294            scenes: &mut self.scene_paint,
1295            device,
1296            queue,
1297        };
1298        self.core.prepare_paint_cached(
1299            |shader| pipelines.contains_key(shader),
1300            |shader| match shader {
1301                ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
1302                ShaderHandle::Stock(_) => false,
1303            },
1304            &mut recorder,
1305            scale_factor,
1306            &mut timings,
1307        );
1308
1309        // Same GPU-upload block as prepare(); time advances even though
1310        // ops are unchanged so time-driven shaders animate.
1311        {
1312            damascene_core::profile_span!("repaint::gpu_upload");
1313            let t_paint_end = Instant::now();
1314            if self.core.quad_scratch.len() > self.instance_capacity {
1315                let new_cap = self.core.quad_scratch.len().next_power_of_two();
1316                self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
1317                    label: Some("damascene_wgpu::instance_buf (resized)"),
1318                    size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
1319                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1320                    mapped_at_creation: false,
1321                });
1322                self.instance_capacity = new_cap;
1323            }
1324            if !self.core.quad_scratch.is_empty() {
1325                queue.write_buffer(
1326                    &self.instance_buf,
1327                    0,
1328                    bytemuck::cast_slice(&self.core.quad_scratch),
1329                );
1330            }
1331            self.text_paint.flush(device, queue);
1332            self.icon_paint.flush(device, queue);
1333            self.image_paint.flush(device, queue);
1334            self.surface_paint.flush(device, queue);
1335            self.scene_paint.flush(device, queue);
1336            let time = match self.core.ui_state().animation_mode() {
1337                AnimationMode::Settled => 0.0,
1338                AnimationMode::Live => (Instant::now() - self.start_time).as_secs_f32(),
1339            };
1340            let frame = FrameUniforms {
1341                viewport: [viewport.w, viewport.h],
1342                time,
1343                scale_factor,
1344                white_scale: self.white_scale,
1345                headroom: self.headroom,
1346                ref_nits: self.ref_nits,
1347                _reserved: 0.0,
1348            };
1349            queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
1350            timings.gpu_upload = Instant::now() - t_paint_end;
1351        }
1352
1353        // Re-evaluate the paint lane against the cached ops so the host
1354        // can re-arm the deadline. Cheap (one scan over already-resolved
1355        // ops). The layout lane is left as `None`: we didn't re-run
1356        // `prepare_layout`, so we have no fresh signal to report — the
1357        // host's previously-set layout deadline still stands.
1358        let time_shaders = &self.time_shaders;
1359        let next_paint_redraw_in = self.core.scan_continuous_shaders(|handle| match handle {
1360            ShaderHandle::Custom(name) => time_shaders.contains(name),
1361            ShaderHandle::Stock(_) => false,
1362        });
1363        PrepareResult {
1364            needs_redraw: next_paint_redraw_in.is_some(),
1365            next_redraw_in: next_paint_redraw_in,
1366            next_layout_redraw_in: None,
1367            next_paint_redraw_in,
1368            timings,
1369        }
1370    }
1371
1372    // ---- Input plumbing ----
1373    //
1374    // The host (winit-side) calls these from its event loop.
1375    // Coordinates are **logical pixels** — divide winit's physical
1376    // PhysicalPosition by the window scale factor before handing them in.
1377
1378    /// Update pointer position and recompute the hovered key.
1379    /// Returns the new hovered key, if any (host can use it for cursor
1380    /// styling or to decide whether to call `request_redraw`).
1381    /// Pointer moved to `p.x, p.y` (logical px). Returns the events to
1382    /// dispatch via `App::on_event` plus a `needs_redraw` flag — see
1383    /// [`PointerMove`] for why hosts must gate `request_redraw` on
1384    /// the flag. The hovered node is updated on `ui_state().hovered`
1385    /// regardless. Mouse-only hosts can construct `p` via
1386    /// [`Pointer::moving`].
1387    pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
1388        self.core.pointer_moved(p)
1389    }
1390
1391    /// Pointer left the window — clear hover/press. Returns a
1392    /// `PointerLeave` event for the previously hovered target (when
1393    /// there was one); hosts should route the events through
1394    /// `App::on_event` like the other pointer entry points.
1395    pub fn pointer_left(&mut self) -> Vec<damascene_core::UiEvent> {
1396        self.core.pointer_left()
1397    }
1398
1399    /// The platform cancelled the pointer sequence (touch cancel /
1400    /// `pointercancel`) — abandons in-flight presses and gesture
1401    /// captures without applying release effects. Route the events
1402    /// through `App::on_event` like the other pointer entry points.
1403    pub fn pointer_cancelled(&mut self) -> Vec<damascene_core::UiEvent> {
1404        self.core.pointer_cancelled()
1405    }
1406
1407    /// File is being dragged over the window. Hosts call this from
1408    /// `winit::WindowEvent::HoveredFile` (one call per file). Returns
1409    /// the `FileHovered` event routed to the keyed leaf at the cursor
1410    /// (or window-level if outside any keyed surface).
1411    pub fn file_hovered(
1412        &mut self,
1413        path: std::path::PathBuf,
1414        x: f32,
1415        y: f32,
1416    ) -> Vec<damascene_core::UiEvent> {
1417        self.core.file_hovered(path, x, y)
1418    }
1419
1420    /// File hover ended without a drop — hosts call this from
1421    /// `winit::WindowEvent::HoveredFileCancelled`. Window-level event
1422    /// (not routed); apps clear any drop-zone affordance.
1423    pub fn file_hover_cancelled(&mut self) -> Vec<damascene_core::UiEvent> {
1424        self.core.file_hover_cancelled()
1425    }
1426
1427    /// File was dropped on the window. Hosts call this from
1428    /// `winit::WindowEvent::DroppedFile` (one call per file).
1429    pub fn file_dropped(
1430        &mut self,
1431        path: std::path::PathBuf,
1432        x: f32,
1433        y: f32,
1434    ) -> Vec<damascene_core::UiEvent> {
1435        self.core.file_dropped(path, x, y)
1436    }
1437
1438    /// Whether a primary press at `(x, y)` (logical px) would land
1439    /// on a node that opted into `capture_keys` — the marker the
1440    /// library uses for text-input-style widgets. Hosts query this
1441    /// from a DOM pointerdown handler to decide whether to focus
1442    /// a hidden textarea (so the soft keyboard can open in the
1443    /// user-gesture context). See
1444    /// [`RunnerCore::would_press_focus_text_input`] for details.
1445    pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
1446        self.core.would_press_focus_text_input(x, y)
1447    }
1448
1449    /// Whether the currently focused node is a text-input-style
1450    /// widget (i.e. has `capture_keys` set). Hosts mirror this each
1451    /// frame into platform affordances such as the on-screen
1452    /// keyboard or IME compose-window placement.
1453    pub fn focused_captures_keys(&self) -> bool {
1454        self.core.focused_captures_keys()
1455    }
1456
1457    /// Pointer pressed at `p.x, p.y` (logical px) for `p.button`. For
1458    /// `Primary`, records the pressed key for press-visual feedback,
1459    /// updates focus, and returns a `PointerDown` event so widgets that
1460    /// need to react at down-time (text input selection anchor,
1461    /// draggable handles) can do so. For `Secondary` / `Middle`, records
1462    /// on a side channel and returns `None`. The actual click event
1463    /// fires on `pointer_up`. Mouse-only hosts can construct `p` via
1464    /// [`Pointer::mouse`].
1465    pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
1466        self.core.pointer_down(p)
1467    }
1468
1469    /// Replace the tracked modifier mask. Hosts call this from their
1470    /// platform's "modifiers changed" hook so subsequent pointer
1471    /// events (PointerDown, Drag, Click, …) stamp the current mask
1472    /// into `UiEvent.modifiers`.
1473    pub fn set_modifiers(&mut self, modifiers: KeyModifiers) {
1474        self.core.ui_state.set_modifiers(modifiers);
1475    }
1476
1477    /// Pointer released at `p.x, p.y` for `p.button`. Returns the
1478    /// events the host should dispatch in order: for `Primary`, always
1479    /// a `PointerUp` (when there was a corresponding down) followed
1480    /// by an optional `Click` (when the up landed on the down's
1481    /// node). For `Secondary` / `Middle`, an optional `SecondaryClick`
1482    /// / `MiddleClick` on the same-node match. Mouse-only hosts can
1483    /// construct `p` via [`Pointer::mouse`].
1484    pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1485        self.core.pointer_up(p)
1486    }
1487
1488    pub fn key_down(
1489        &mut self,
1490        logical: LogicalKey,
1491        physical: PhysicalKey,
1492        modifiers: KeyModifiers,
1493        repeat: bool,
1494    ) -> Vec<UiEvent> {
1495        self.core.key_down(logical, physical, modifiers, repeat)
1496    }
1497
1498    /// Forward an OS-composed text-input string (winit's keyboard event
1499    /// `.text` field, or an `Ime::Commit`) to the focused element as a
1500    /// `TextInput` event.
1501    pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1502        self.core.text_input(text)
1503    }
1504
1505    /// Replace the hotkey registry. Call once per frame, after `app.build()`,
1506    /// passing `app.hotkeys()` so chords stay in sync with state.
1507    ///
1508    /// The registry is scoped to this `Runner` — in a multi-window
1509    /// host (one `Runner` per window), pass each window only its own
1510    /// list and feed each window's key events only to its own
1511    /// `Runner`; chords then fire per focused window. See
1512    /// `damascene_core::App::hotkeys` for the full convention.
1513    pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1514        self.core.set_hotkeys(hotkeys);
1515    }
1516
1517    /// Push the app's current selection to the runtime so the painter
1518    /// can draw highlight bands. Hosts call this once per frame
1519    /// alongside [`Self::set_hotkeys`].
1520    pub fn set_selection(&mut self, selection: damascene_core::selection::Selection) {
1521        self.core.set_selection(selection);
1522    }
1523
1524    /// Resolve the runtime's current selection to a text payload from
1525    /// the most recently laid-out tree. See
1526    /// [`RunnerCore::selected_text`] — virtual-list rows are realized
1527    /// during layout, so a freshly built app tree would miss them and
1528    /// a `Ctrl+C` lookup that walked it would silently come back empty.
1529    pub fn selected_text(&self) -> Option<String> {
1530        self.core.selected_text()
1531    }
1532
1533    /// Resolve an explicit [`damascene_core::selection::Selection`] against
1534    /// the last laid-out tree. See [`RunnerCore::selected_text_for`].
1535    pub fn selected_text_for(
1536        &self,
1537        selection: &damascene_core::selection::Selection,
1538    ) -> Option<String> {
1539        self.core.selected_text_for(selection)
1540    }
1541
1542    /// Queue toast specs onto the runtime's toast stack. Hosts call
1543    /// this once per frame with `app.drain_toasts()`. Each spec is
1544    /// stamped with a monotonic id and an `expires_at` deadline
1545    /// (`now + ttl`); the next `prepare` call drops expired entries
1546    /// and synthesizes a `toast_stack` floating layer over the rest.
1547    pub fn push_toasts(&mut self, specs: Vec<damascene_core::toast::ToastSpec>) {
1548        self.core.push_toasts(specs);
1549    }
1550
1551    /// Programmatically dismiss a toast by id. Useful for cancelling
1552    /// long-TTL toasts when an external condition resolves (e.g.,
1553    /// "reconnecting…" turning into "connected").
1554    pub fn dismiss_toast(&mut self, id: u64) {
1555        self.core.dismiss_toast(id);
1556    }
1557
1558    /// Queue programmatic focus requests by widget key. Hosts call
1559    /// this once per frame with `app.drain_focus_requests()`. Each
1560    /// key is resolved during the next `prepare` against the rebuilt
1561    /// focus order; unmatched keys drop silently.
1562    pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1563        self.core.push_focus_requests(keys);
1564    }
1565
1566    /// Queue programmatic scroll-to-row requests targeting virtual
1567    /// lists by key. Hosts call this once per frame with
1568    /// `app.drain_scroll_requests()`. Each request is consumed during
1569    /// the next `prepare` by the layout pass for the matching list,
1570    /// where viewport height and row heights are known. Unmatched
1571    /// list keys and out-of-range row indices drop silently.
1572    pub fn push_scroll_requests(&mut self, requests: Vec<damascene_core::scroll::ScrollRequest>) {
1573        self.core.push_scroll_requests(requests);
1574    }
1575
1576    pub fn push_viewport_requests(
1577        &mut self,
1578        requests: Vec<damascene_core::viewport::ViewportRequest>,
1579    ) {
1580        self.core.push_viewport_requests(requests);
1581    }
1582
1583    pub fn push_plot_requests(&mut self, requests: Vec<damascene_core::plot::PlotRequest>) {
1584        self.core.push_plot_requests(requests);
1585    }
1586
1587    /// Switch animation pacing. Default is [`AnimationMode::Live`].
1588    /// Headless render binaries should call this with
1589    /// [`AnimationMode::Settled`] so a single-frame snapshot reflects
1590    /// the post-animation visual without depending on integrator timing.
1591    pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1592        self.core.set_animation_mode(mode);
1593    }
1594
1595    /// Apply a wheel delta in **logical** pixels at `(x, y)`. Routes to
1596    /// the deepest scrollable container under the cursor in the last
1597    /// laid-out tree. Returns `true` if the event landed on a scrollable
1598    /// (host should `request_redraw` so the next frame applies the new
1599    /// offset).
1600    pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1601        self.core.pointer_wheel(x, y, dy)
1602    }
1603
1604    /// Build a routed wheel event for the keyed target under `(x, y)`.
1605    ///
1606    /// Dispatch this before [`Self::pointer_wheel`]; if the app
1607    /// consumes the event, skip the fallback scroll call.
1608    pub fn pointer_wheel_event(
1609        &mut self,
1610        x: f32,
1611        y: f32,
1612        dx: f32,
1613        dy: f32,
1614    ) -> Option<damascene_core::UiEvent> {
1615        self.core.pointer_wheel_event(x, y, dx, dy)
1616    }
1617
1618    /// Drain time-driven input events whose deadline has passed (touch
1619    /// long-press today; later: hold-to-repeat, etc.). Hosts call this
1620    /// once per frame before dispatching pointer events. `now` is
1621    /// `web_time::Instant` rather than `std::time::Instant` so the
1622    /// signature compiles on wasm32 — `web_time` aliases to std on
1623    /// native, so existing native callers passing `Instant::now()`
1624    /// from std still work. See [`damascene_core::RunnerCore::poll_input`].
1625    pub fn poll_input(&mut self, now: web_time::Instant) -> Vec<damascene_core::UiEvent> {
1626        self.core.poll_input(now)
1627    }
1628
1629    /// Record draws into the host-managed render pass. Call after
1630    /// [`Self::prepare`]. Paint order follows the draw-op stream.
1631    ///
1632    /// **No backdrop sampling.** This entry point cannot honor pass
1633    /// boundaries (the host owns the pass lifetime), so any
1634    /// `BackdropSnapshot` items in the paint stream are no-ops and any
1635    /// shader bound with `samples_backdrop=true` reads an undefined
1636    /// backdrop binding. Use [`Self::render`] for backdrop-aware
1637    /// rendering.
1638    ///
1639    /// **3D scenes need the pre-pass.** `Scene3D` paint items
1640    /// composite from offscreen targets that must be rendered before
1641    /// the host's pass begins — call [`Self::encode_scene_prepass`] on
1642    /// the encoder first, or every scene in the frame samples a
1643    /// never-rendered target and composites blank.
1644    pub fn draw<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>) {
1645        self.draw_items(pass, &self.core.paint_items);
1646    }
1647
1648    /// Encode the offscreen pre-pass for any 3D scenes in this frame's
1649    /// paint stream: each `Scene3D` renders into its own offscreen
1650    /// target, and label-bearing scenes capture depth for next frame's
1651    /// label occlusion. No-op when the frame has no scenes.
1652    ///
1653    /// [`Self::render`] calls this automatically. Hosts using
1654    /// [`Self::draw`] must call it on their encoder after
1655    /// [`Self::prepare`] and *before* beginning the render pass that
1656    /// `draw` records into.
1657    pub fn encode_scene_prepass(
1658        &mut self,
1659        device: &wgpu::Device,
1660        encoder: &mut wgpu::CommandEncoder,
1661    ) {
1662        if self.scene_paint.has_runs() {
1663            self.scene_paint.encode_offscreen(encoder);
1664            // Capture each label-bearing scene's depth into its read-back
1665            // buffer (the depth is still alive from the pass above). The
1666            // map + CPU read happens next frame in `prepare`.
1667            self.scene_paint.encode_depth_capture(device, encoder);
1668        }
1669    }
1670
1671    /// Record draws into a host-supplied encoder, owning pass
1672    /// lifetimes ourselves so backdrop-sampling shaders can sample a
1673    /// snapshot of Pass A's content.
1674    ///
1675    /// The host hands us:
1676    /// - the encoder (we record into it),
1677    /// - the color target's `wgpu::Texture` (used as `copy_src` when
1678    ///   we snapshot it; must include `COPY_SRC` in its usage flags),
1679    /// - the corresponding `wgpu::TextureView` (we attach it to every
1680    ///   render pass we begin), and
1681    /// - the `LoadOp` to use on the *first* pass — `Clear(color)` to
1682    ///   clear behind us, `Load` to composite onto whatever was
1683    ///   already in the target.
1684    ///
1685    /// Multi-pass schedule when the paint stream contains a
1686    /// `BackdropSnapshot`:
1687    ///
1688    /// 1. Pass A — every paint item before the snapshot, with the
1689    ///    caller-supplied `LoadOp`.
1690    /// 2. `copy_texture_to_texture` — target → snapshot.
1691    /// 3. Pass B — paint items from the snapshot onward, with
1692    ///    `LoadOp::Load` so Pass A's pixels remain underneath.
1693    ///
1694    /// Without a snapshot, this collapses to a single pass and is
1695    /// equivalent to [`Self::draw`] called inside a host-managed
1696    /// pass with the same `LoadOp`.
1697    pub fn render(
1698        &mut self,
1699        device: &wgpu::Device,
1700        encoder: &mut wgpu::CommandEncoder,
1701        target_tex: &wgpu::Texture,
1702        target_view: &wgpu::TextureView,
1703        msaa_view: Option<&wgpu::TextureView>,
1704        load_op: wgpu::LoadOp<wgpu::Color>,
1705    ) {
1706        // When MSAA is in use, the actual color attachment is the
1707        // multisampled view and `target_view` becomes its resolve
1708        // target. `target_tex` is always the resolved (single-sample)
1709        // texture, so the snapshot copy below works whether MSAA is on
1710        // or not — the resolve happens at end-of-Pass-A.
1711        let attachment_view = msaa_view.unwrap_or(target_view);
1712        let resolve_target = msaa_view.map(|_| target_view);
1713
1714        // Phase 1: render every recorded 3D scene into its own offscreen
1715        // target. Passes can't nest, so this is encoded on `encoder` ahead
1716        // of the main composite pass (same discipline as BackdropSnapshot).
1717        // The `PaintItem::Scene3D` arm below then composites the resolved
1718        // textures into the main pass.
1719        self.encode_scene_prepass(device, encoder);
1720
1721        // Locate the (at most one) snapshot boundary.
1722        let split_at = self
1723            .core
1724            .paint_items
1725            .iter()
1726            .position(|p| matches!(p, PaintItem::BackdropSnapshot));
1727
1728        if let Some(idx) = split_at {
1729            self.ensure_snapshot(device, target_tex);
1730            // Pass A
1731            {
1732                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1733                    label: Some("damascene_wgpu::pass_a"),
1734                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1735                        view: attachment_view,
1736                        resolve_target,
1737                        depth_slice: None,
1738                        ops: wgpu::Operations {
1739                            load: load_op,
1740                            store: wgpu::StoreOp::Store,
1741                        },
1742                    })],
1743                    depth_stencil_attachment: None,
1744                    timestamp_writes: None,
1745                    occlusion_query_set: None,
1746                    multiview_mask: None,
1747                });
1748                self.draw_items(&mut pass, &self.core.paint_items[..idx]);
1749            }
1750            // Snapshot copy. Target must support COPY_SRC; snapshot
1751            // texture (created in `ensure_snapshot`) supports COPY_DST
1752            // + TEXTURE_BINDING.
1753            let snapshot = self.snapshot.as_ref().expect("snapshot ensured");
1754            encoder.copy_texture_to_texture(
1755                wgpu::TexelCopyTextureInfo {
1756                    texture: target_tex,
1757                    mip_level: 0,
1758                    origin: wgpu::Origin3d::ZERO,
1759                    aspect: wgpu::TextureAspect::All,
1760                },
1761                wgpu::TexelCopyTextureInfo {
1762                    texture: &snapshot.texture,
1763                    mip_level: 0,
1764                    origin: wgpu::Origin3d::ZERO,
1765                    aspect: wgpu::TextureAspect::All,
1766                },
1767                wgpu::Extent3d {
1768                    width: snapshot.extent.0,
1769                    height: snapshot.extent.1,
1770                    depth_or_array_layers: 1,
1771                },
1772            );
1773            // Pass B
1774            {
1775                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1776                    label: Some("damascene_wgpu::pass_b"),
1777                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1778                        view: attachment_view,
1779                        resolve_target,
1780                        depth_slice: None,
1781                        ops: wgpu::Operations {
1782                            load: wgpu::LoadOp::Load,
1783                            store: wgpu::StoreOp::Store,
1784                        },
1785                    })],
1786                    depth_stencil_attachment: None,
1787                    timestamp_writes: None,
1788                    occlusion_query_set: None,
1789                    multiview_mask: None,
1790                });
1791                // Skip the snapshot item itself; it's a marker, not a draw.
1792                self.draw_items(&mut pass, &self.core.paint_items[idx + 1..]);
1793            }
1794        } else {
1795            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1796                label: Some("damascene_wgpu::pass"),
1797                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1798                    view: attachment_view,
1799                    resolve_target,
1800                    depth_slice: None,
1801                    ops: wgpu::Operations {
1802                        load: load_op,
1803                        store: wgpu::StoreOp::Store,
1804                    },
1805                })],
1806                depth_stencil_attachment: None,
1807                timestamp_writes: None,
1808                occlusion_query_set: None,
1809                multiview_mask: None,
1810            });
1811            self.draw_items(&mut pass, &self.core.paint_items);
1812        }
1813    }
1814
1815    /// (Re)allocate the snapshot texture to match `target_tex`'s
1816    /// extent + format. Idempotent when the size matches; rebuilds the
1817    /// `backdrop_bind_group` whenever the snapshot is recreated.
1818    fn ensure_snapshot(&mut self, device: &wgpu::Device, target_tex: &wgpu::Texture) {
1819        let extent = target_tex.size();
1820        let want = (extent.width, extent.height);
1821        if let Some(s) = &self.snapshot
1822            && s.extent == want
1823        {
1824            return;
1825        }
1826        let texture = device.create_texture(&wgpu::TextureDescriptor {
1827            label: Some("damascene_wgpu::backdrop_snapshot"),
1828            size: wgpu::Extent3d {
1829                width: want.0,
1830                height: want.1,
1831                depth_or_array_layers: 1,
1832            },
1833            mip_level_count: 1,
1834            sample_count: 1,
1835            dimension: wgpu::TextureDimension::D2,
1836            format: self.target_format,
1837            usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
1838            view_formats: &[],
1839        });
1840        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1841        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1842            label: Some("damascene_wgpu::backdrop_bind_group"),
1843            layout: &self.backdrop_bind_layout,
1844            entries: &[
1845                wgpu::BindGroupEntry {
1846                    binding: 0,
1847                    resource: wgpu::BindingResource::TextureView(&view),
1848                },
1849                wgpu::BindGroupEntry {
1850                    binding: 1,
1851                    resource: wgpu::BindingResource::Sampler(&self.backdrop_sampler),
1852                },
1853            ],
1854        });
1855        self.snapshot = Some(SnapshotTexture {
1856            texture,
1857            extent: want,
1858        });
1859        self.backdrop_bind_group = Some(bind_group);
1860    }
1861
1862    /// Walk a slice of `PaintItem`s into the given pass. Helper shared
1863    /// by [`Self::draw`] and [`Self::render`]. `BackdropSnapshot`
1864    /// items are no-ops here; `render()` handles them by splitting
1865    /// the slice before passing to this helper.
1866    fn draw_items<'pass>(
1867        &'pass self,
1868        pass: &mut wgpu::RenderPass<'pass>,
1869        items: &'pass [PaintItem],
1870    ) {
1871        let full = PhysicalScissor {
1872            x: 0,
1873            y: 0,
1874            w: self.core.viewport_px.0,
1875            h: self.core.viewport_px.1,
1876        };
1877        // Redundant-state elision. Paint items arrive in z-order, so
1878        // consecutive items very often share scissor / pipeline / bind
1879        // groups / vertex buffers; re-setting them per item made wgpu's
1880        // per-call validation the dominant submit cost at high op
1881        // counts. Bind-group and buffer identity is by pointer: every
1882        // arm below binds long-lived objects owned by `self`, so equal
1883        // pointers mean the identical binding. WebGPU binding state
1884        // persists across pipeline switches, so skipping an equal
1885        // rebinding is behavior-identical.
1886        let mut state = DrawItemState::default();
1887        for item in items {
1888            match *item {
1889                PaintItem::QuadRun(index) => {
1890                    let run = &self.core.runs[index];
1891                    state.scissor(pass, run.scissor, full);
1892                    state.bind(pass, 0, &self.quad_bind_group);
1893                    let is_backdrop_shader = matches!(
1894                        run.handle,
1895                        ShaderHandle::Custom(name) if self.backdrop_shaders.contains(name)
1896                    );
1897                    if is_backdrop_shader && let Some(bg) = &self.backdrop_bind_group {
1898                        state.bind(pass, 1, bg);
1899                    }
1900                    state.vbuf(pass, 0, &self.quad_vbo);
1901                    state.vbuf(pass, 1, &self.instance_buf);
1902                    let pipeline = self
1903                        .pipelines
1904                        .get(&run.handle)
1905                        .expect("run handle has no pipeline (bug in prepare)");
1906                    state.pipeline(pass, pipeline);
1907                    pass.draw(0..4, run.first..run.first + run.count);
1908                }
1909                PaintItem::Text(index) => {
1910                    let run = self.text_paint.run(index);
1911                    state.scissor(pass, run.scissor, full);
1912                    state.pipeline(pass, self.text_paint.pipeline_for(run.kind));
1913                    state.bind(pass, 0, &self.quad_bind_group);
1914                    // Highlight runs use a frame-uniform-only pipeline.
1915                    // Glyph kinds bind the active atlas page at group 1.
1916                    if !matches!(run.kind, crate::text::TextRunKind::Highlight) {
1917                        state.bind(pass, 1, self.text_paint.page_bind_group(run.kind, run.page));
1918                    }
1919                    state.vbuf(pass, 0, &self.quad_vbo);
1920                    state.vbuf(pass, 1, self.text_paint.instance_buf_for(run.kind));
1921                    pass.draw(0..4, run.first..run.first + run.count);
1922                }
1923                PaintItem::IconRun(index) | PaintItem::Vector(index) => {
1924                    // `PaintItem::Vector` is structurally identical to
1925                    // `PaintItem::IconRun` — both index into the same
1926                    // `IconPaint::runs` Vec since `record_vector`
1927                    // appends there too. The variant is kept distinct
1928                    // for paint-stream provenance (icon vs app vector)
1929                    // but the dispatch is the same.
1930                    let run = self.icon_paint.run(index);
1931                    state.scissor(pass, run.scissor, full);
1932                    match run.kind {
1933                        IconRunKind::Tess => {
1934                            state.pipeline(pass, self.icon_paint.tess_pipeline(run.material));
1935                            state.bind(pass, 0, &self.quad_bind_group);
1936                            state.vbuf(pass, 0, self.icon_paint.tess_vertex_buf());
1937                            pass.draw(run.first..run.first + run.count, 0..1);
1938                        }
1939                        IconRunKind::Msdf => {
1940                            state.pipeline(pass, self.icon_paint.msdf_pipeline());
1941                            state.bind(pass, 0, &self.quad_bind_group);
1942                            state.bind(pass, 1, self.icon_paint.msdf_page_bind_group(run.page));
1943                            state.vbuf(pass, 0, &self.quad_vbo);
1944                            state.vbuf(pass, 1, self.icon_paint.msdf_instance_buf());
1945                            pass.draw(0..4, run.first..run.first + run.count);
1946                        }
1947                    }
1948                }
1949                PaintItem::Image(index) => {
1950                    let run = self.image_paint.run(index);
1951                    state.scissor(pass, run.scissor, full);
1952                    state.pipeline(pass, self.image_paint.pipeline());
1953                    state.bind(pass, 0, &self.quad_bind_group);
1954                    state.bind(pass, 1, self.image_paint.bind_group_for_run(run));
1955                    state.vbuf(pass, 0, &self.quad_vbo);
1956                    state.vbuf(pass, 1, self.image_paint.instance_buf());
1957                    pass.draw(0..4, run.first..run.first + run.count);
1958                }
1959                PaintItem::AppTexture(index) => {
1960                    let run = self.surface_paint.run(index);
1961                    state.scissor(pass, run.scissor, full);
1962                    state.pipeline(pass, self.surface_paint.pipeline_for(run.alpha));
1963                    state.bind(pass, 0, &self.quad_bind_group);
1964                    state.bind(pass, 1, self.surface_paint.bind_group_for_run(run));
1965                    state.vbuf(pass, 0, &self.quad_vbo);
1966                    state.vbuf(pass, 1, self.surface_paint.instance_buf());
1967                    pass.draw(0..4, run.first..run.first + run.count);
1968                }
1969                PaintItem::Scene3D(index) => {
1970                    // The scene already rendered + resolved offscreen in
1971                    // phase 1; composite that texture over the rect via the
1972                    // stock surface pipeline (premultiplied).
1973                    let run = self.scene_paint.run(index);
1974                    state.scissor(pass, run.scissor, full);
1975                    state.pipeline(pass, self.scene_paint.composite_pipeline());
1976                    state.bind(pass, 0, &self.quad_bind_group);
1977                    state.bind(pass, 1, self.scene_paint.composite_bind_group(run));
1978                    state.vbuf(pass, 0, &self.quad_vbo);
1979                    state.vbuf(pass, 1, self.scene_paint.composite_instance_buf());
1980                    pass.draw(0..4, run.composite_instance..run.composite_instance + 1);
1981                }
1982                PaintItem::BackdropSnapshot => {
1983                    // Marker only — `render()` splits the slice on
1984                    // these and never includes one in a draw range.
1985                }
1986            }
1987        }
1988    }
1989}
1990
1991/// Last-set render-pass state for [`Renderer::draw_items`]'s
1992/// redundant-call elision. Identity is by pointer for GPU objects
1993/// (they're all long-lived fields of the renderer) and by value for
1994/// the scissor rect.
1995#[derive(Default)]
1996struct DrawItemState<'pass> {
1997    scissor: Option<Option<PhysicalScissor>>,
1998    pipeline: Option<&'pass wgpu::RenderPipeline>,
1999    bind_groups: [Option<&'pass wgpu::BindGroup>; 2],
2000    vertex_bufs: [Option<&'pass wgpu::Buffer>; 2],
2001}
2002
2003impl<'pass> DrawItemState<'pass> {
2004    fn scissor(
2005        &mut self,
2006        pass: &mut wgpu::RenderPass<'_>,
2007        scissor: Option<PhysicalScissor>,
2008        full: PhysicalScissor,
2009    ) {
2010        if self.scissor != Some(scissor) {
2011            set_scissor(pass, scissor, full);
2012            self.scissor = Some(scissor);
2013        }
2014    }
2015
2016    fn pipeline(&mut self, pass: &mut wgpu::RenderPass<'_>, pipeline: &'pass wgpu::RenderPipeline) {
2017        if !self.pipeline.is_some_and(|cur| std::ptr::eq(cur, pipeline)) {
2018            pass.set_pipeline(pipeline);
2019            self.pipeline = Some(pipeline);
2020        }
2021    }
2022
2023    fn bind(&mut self, pass: &mut wgpu::RenderPass<'_>, slot: u32, group: &'pass wgpu::BindGroup) {
2024        let cur = &mut self.bind_groups[slot as usize];
2025        if !cur.is_some_and(|cur| std::ptr::eq(cur, group)) {
2026            pass.set_bind_group(slot, group, &[]);
2027            *cur = Some(group);
2028        }
2029    }
2030
2031    fn vbuf(&mut self, pass: &mut wgpu::RenderPass<'_>, slot: u32, buf: &'pass wgpu::Buffer) {
2032        let cur = &mut self.vertex_bufs[slot as usize];
2033        if !cur.is_some_and(|cur| std::ptr::eq(cur, buf)) {
2034            pass.set_vertex_buffer(slot, buf.slice(..));
2035            *cur = Some(buf);
2036        }
2037    }
2038}