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, &mut 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, Pointer, UiEvent, UiKey};
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 /// The [`SharedText`] pool this runner records text into. Hand it
898 /// to [`Self::with_shared_text`] when constructing further runners
899 /// on the same device so they share fonts, shaping, and atlases —
900 /// works whether this runner was built with a shared pool or owns
901 /// a private one.
902 pub fn shared_text(&self) -> SharedText {
903 self.text_paint.shared().clone()
904 }
905
906 pub fn set_theme(&mut self, theme: Theme) {
907 self.icon_paint.set_material(theme.icon_material());
908 self.core.set_theme(theme);
909 }
910
911 pub fn theme(&self) -> &Theme {
912 self.core.theme()
913 }
914
915 /// Select the stock material used by the vector-icon painter.
916 /// Prefer [`Theme::with_icon_material`] for app-level routing; this
917 /// remains useful for low-level render fixtures.
918 pub fn set_icon_material(&mut self, material: IconMaterial) {
919 self.icon_paint.set_material(material);
920 }
921
922 pub fn icon_material(&self) -> IconMaterial {
923 self.icon_paint.material()
924 }
925
926 /// Register a custom shader. `name` is the same string passed to
927 /// `damascene_core::shader::ShaderBinding::custom`; nodes bound to it
928 /// via [`El::shader`](damascene_core::tree::El) paint through this
929 /// pipeline.
930 ///
931 /// The WGSL source must use the shared `(rect, vec_a, vec_b, vec_c)`
932 /// instance layout and the `FrameUniforms` bind group described in
933 /// the module docs. Compilation happens at register time — invalid
934 /// WGSL panics here, not mid-frame.
935 ///
936 /// Re-registering the same name replaces the previous pipeline
937 /// (useful for hot-reload during development).
938 pub fn register_shader(&mut self, device: &wgpu::Device, name: &'static str, wgsl: &str) {
939 self.register_shader_with(device, name, wgsl, false, false);
940 }
941
942 /// Register a custom shader, with opt-in flags for backdrop
943 /// sampling and time-driven motion.
944 ///
945 /// `samples_backdrop=true` schedules the shader's draws into
946 /// Pass B (after a snapshot of Pass A's rendered content) and
947 /// binds the snapshot texture as `@group(2) binding=0`
948 /// (`backdrop_tex`) plus a sampler at `binding=1`
949 /// (`backdrop_smp`). See `docs/SHADER_VISION.md` §"Backdrop
950 /// sampling architecture". Backdrop depth is capped at 1.
951 ///
952 /// `samples_time=true` declares that the shader's output depends
953 /// on `frame.time`. The runtime ORs this into
954 /// [`PrepareResult::needs_redraw`] for any frame that has at
955 /// least one node bound to the shader, so the host idle loop
956 /// keeps ticking without a per-El opt-in. Stock shaders self-
957 /// report through [`damascene_core::shader::StockShader::is_continuous`];
958 /// this flag is the same signal for app-registered WGSL.
959 pub fn register_shader_with(
960 &mut self,
961 device: &wgpu::Device,
962 name: &'static str,
963 wgsl: &str,
964 samples_backdrop: bool,
965 samples_time: bool,
966 ) {
967 let label = format!("custom::{name}");
968 let layout = if samples_backdrop {
969 &self.backdrop_pipeline_layout
970 } else {
971 &self.pipeline_layout
972 };
973 let pipeline = build_quad_pipeline(
974 device,
975 layout,
976 self.target_format,
977 self.sample_count,
978 &label,
979 wgsl,
980 self.per_sample_shading,
981 );
982 self.pipelines.insert(ShaderHandle::Custom(name), pipeline);
983 // Retain the source so the pipeline can be rebuilt against a new
984 // swapchain format in `set_target_format`. Re-registering replaces
985 // the prior entry, matching the pipeline-map replacement above.
986 self.custom_shaders
987 .insert(name, (wgsl.to_string(), samples_backdrop));
988 // Introspect the instance-attribute names so this shader's
989 // uniforms route by WGSL field name with Rust↔WGSL drift
990 // detection (issue #99). Failure is non-fatal: positional
991 // vec_a..vec_e routing still works.
992 match damascene_core::paint::slots::introspect_wgsl(wgsl) {
993 Ok(map) => self.core.register_shader_slots(name, map),
994 Err(e) => log::warn!(
995 "damascene-wgpu: could not introspect shader `{name}` for named uniform \
996 routing ({e}); positional vec_a..vec_e routing still applies"
997 ),
998 }
999 if samples_backdrop {
1000 self.backdrop_shaders.insert(name);
1001 } else {
1002 self.backdrop_shaders.remove(name);
1003 }
1004 if samples_time {
1005 self.time_shaders.insert(name);
1006 } else {
1007 self.time_shaders.remove(name);
1008 }
1009 }
1010
1011 /// Borrow the internal [`UiState`] — primarily for headless fixtures
1012 /// that want to look up a node's rect after `prepare` (e.g., to
1013 /// simulate a pointer at a specific button's center).
1014 pub fn ui_state(&self) -> &UiState {
1015 self.core.ui_state()
1016 }
1017
1018 /// One-line diagnostic snapshot of interactive state — passes through
1019 /// to [`UiState::debug_summary`]. Intended for per-frame logging
1020 /// (e.g., `console.log` from the wasm host while debugging hover /
1021 /// animation glitches).
1022 pub fn debug_summary(&self) -> String {
1023 self.core.debug_summary()
1024 }
1025
1026 /// Return the most recently laid-out rectangle for a keyed node.
1027 ///
1028 /// Call after [`Self::prepare`]. This is the host-composition hook:
1029 /// reserve a keyed Damascene element in the UI tree, ask for its rect
1030 /// here, then record host-owned rendering into that region using the
1031 /// same encoder / render flow that surrounds Damascene's pass.
1032 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
1033 self.core.rect_of_key(key)
1034 }
1035
1036 /// Lay out the tree, resolve to draw ops, and upload per-frame
1037 /// buffers (quad instances + glyph atlas). Must be called before
1038 /// [`Self::draw`] and outside of any render pass.
1039 ///
1040 /// `viewport` is in **logical** pixels — the units the layout pass
1041 /// works in. `scale_factor` is the HiDPI multiplier (1.0 on a
1042 /// regular display, 2.0 on most modern HiDPI, can be fractional).
1043 /// The host's render-pass target should be sized at physical pixels
1044 /// (`viewport × scale_factor`); the runner maps logical → physical
1045 /// internally so layout, fonts, and SDF math stay device-independent.
1046 pub fn prepare(
1047 &mut self,
1048 device: &wgpu::Device,
1049 queue: &wgpu::Queue,
1050 root: &mut El,
1051 viewport: Rect,
1052 scale_factor: f32,
1053 ) -> PrepareResult {
1054 let mut timings = PrepareTimings::default();
1055
1056 // Install any scene depth maps that finished reading back (a frame
1057 // or two late) so this frame's `draw_ops` can occlude scene-anchored
1058 // labels behind geometry. Done before `prepare_layout` runs the
1059 // draw-op pass. Stale maps for scenes that left the tree are GC'd.
1060 let ready_depth = self.scene_paint.collect_depth_maps(device);
1061 if !ready_depth.is_empty() {
1062 let depth_maps = self.core.ui_state.scene_depth_mut();
1063 for (id, map) in ready_depth {
1064 depth_maps.insert(id, map);
1065 }
1066 }
1067 self.core
1068 .ui_state
1069 .scene_depth_mut()
1070 .retain(|id, _| self.scene_paint.has_target(id));
1071
1072 // Layout + state apply + animation tick + draw_ops resolution.
1073 // Writes timings.layout + timings.draw_ops. The closure feeds
1074 // the runtime's continuous-redraw scan: any node bound to a
1075 // shader registered with `samples_time=true` keeps the host
1076 // loop ticking even when no animation is settling.
1077 let time_shaders = &self.time_shaders;
1078 let LayoutPrepared {
1079 ops,
1080 mut needs_redraw,
1081 mut next_layout_redraw_in,
1082 next_paint_redraw_in,
1083 } = self
1084 .core
1085 .prepare_layout(
1086 root,
1087 viewport,
1088 scale_factor,
1089 &mut timings,
1090 |handle| match handle {
1091 ShaderHandle::Custom(name) => time_shaders.contains(name),
1092 ShaderHandle::Stock(_) => false,
1093 },
1094 );
1095
1096 // Paint stream: pack quads, record text, preserve z-order. The
1097 // closure is the wgpu-specific "is this shader registered?"
1098 // query (different pipeline types per backend prevent moving the
1099 // check itself into core).
1100 self.text_paint.frame_begin();
1101 self.icon_paint.frame_begin();
1102 self.image_paint.frame_begin();
1103 self.surface_paint.frame_begin();
1104 self.scene_paint.frame_begin();
1105 let pipelines = &self.pipelines;
1106 let backdrop_shaders = &self.backdrop_shaders;
1107 let mut recorder = PaintRecorder {
1108 text: &mut self.text_paint,
1109 icons: &mut self.icon_paint,
1110 images: &mut self.image_paint,
1111 surfaces: &mut self.surface_paint,
1112 scenes: &mut self.scene_paint,
1113 device,
1114 queue,
1115 };
1116 self.core.prepare_paint(
1117 &ops,
1118 |shader| pipelines.contains_key(shader),
1119 |shader| match shader {
1120 ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
1121 ShaderHandle::Stock(_) => false,
1122 },
1123 &mut recorder,
1124 scale_factor,
1125 &mut timings,
1126 );
1127
1128 // GPU upload — wgpu-specific. Resize the instance buffer if
1129 // needed, then write quad_scratch + frame uniforms + flush text
1130 // atlas dirty regions. Wrapped in its own scope so the
1131 // `prepare::gpu_upload` span doesn't bleed into the subsequent
1132 // `snapshot` call (which carries its own span).
1133 {
1134 damascene_core::profile_span!("prepare::gpu_upload");
1135 let t_paint_end = Instant::now();
1136 if self.core.quad_scratch.len() > self.instance_capacity {
1137 let new_cap = self.core.quad_scratch.len().next_power_of_two();
1138 self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
1139 label: Some("damascene_wgpu::instance_buf (resized)"),
1140 size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
1141 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1142 mapped_at_creation: false,
1143 });
1144 self.instance_capacity = new_cap;
1145 }
1146 if !self.core.quad_scratch.is_empty() {
1147 queue.write_buffer(
1148 &self.instance_buf,
1149 0,
1150 bytemuck::cast_slice(&self.core.quad_scratch),
1151 );
1152 }
1153 self.text_paint.flush(device, queue);
1154 self.icon_paint.flush(device, queue);
1155 self.image_paint.flush(device, queue);
1156 self.surface_paint.flush(device, queue);
1157 self.scene_paint.flush(device, queue);
1158 // Pin time to 0 in Settled mode so headless fixtures rendering
1159 // a time-driven shader (e.g. stock::spinner) stay byte-identical
1160 // run-to-run, the same way `Animation::settle()` makes the
1161 // spring/tween path deterministic for SVG/PNG snapshots.
1162 let time = match self.core.ui_state().animation_mode() {
1163 damascene_core::AnimationMode::Settled => 0.0,
1164 damascene_core::AnimationMode::Live => {
1165 (Instant::now() - self.start_time).as_secs_f32()
1166 }
1167 };
1168 let frame = FrameUniforms {
1169 viewport: [viewport.w, viewport.h],
1170 time,
1171 scale_factor,
1172 white_scale: self.white_scale,
1173 headroom: self.headroom,
1174 ref_nits: self.ref_nits,
1175 _reserved: 0.0,
1176 };
1177 queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
1178 timings.gpu_upload = Instant::now() - t_paint_end;
1179 }
1180
1181 // Snapshot the laid-out tree for next-frame hit-testing.
1182 self.core.snapshot(root, &mut timings);
1183
1184 // Move resolved ops into the core's cache so a subsequent
1185 // paint-only frame can reuse them without re-running layout.
1186 self.core.last_ops = ops;
1187
1188 // Damascene renders lazily, but the label-occlusion depth read-back needs
1189 // a few frames to resolve. Keep frames coming until every labelled
1190 // scene has a depth map matching its current pose — otherwise a
1191 // capture started in `render` would sit unmapped after the camera
1192 // settles and the labels would never appear. Settled + current scenes
1193 // (and label-free ones) report `false`, so lazy idle is preserved.
1194 //
1195 // This must drive `next_layout_redraw_in`, not just `needs_redraw`:
1196 // hosts schedule the next frame off the deadline lanes (the winit
1197 // host ignores `needs_redraw`), and it must be the *layout* lane, not
1198 // the paint lane — the paint-only `repaint` path skips
1199 // `collect_depth_maps`, so only a full `prepare` advances the readback.
1200 if self.scene_paint.occlusion_unsettled() {
1201 needs_redraw = true;
1202 next_layout_redraw_in = Some(std::time::Duration::ZERO);
1203 }
1204
1205 let next_redraw_in = match (next_layout_redraw_in, next_paint_redraw_in) {
1206 (Some(a), Some(b)) => Some(a.min(b)),
1207 (Some(d), None) | (None, Some(d)) => Some(d),
1208 (None, None) => None,
1209 };
1210 PrepareResult {
1211 needs_redraw,
1212 next_redraw_in,
1213 next_layout_redraw_in,
1214 next_paint_redraw_in,
1215 timings,
1216 }
1217 }
1218
1219 /// Paint-only frame: rerun [`RunnerCore::prepare_paint_cached`] +
1220 /// GPU upload + frame-uniform write against the cached ops from
1221 /// the most recent [`Self::prepare`] call. Skips rebuild + layout
1222 /// + draw_ops + snapshot — only `frame.time` advances.
1223 ///
1224 /// Hosts call this when [`PrepareResult::next_paint_redraw_in`]
1225 /// fires (a time-driven shader needs another frame) and no input
1226 /// has been processed since the last full prepare. Input always
1227 /// upgrades to the full `prepare(...)` path.
1228 ///
1229 /// `viewport` and `scale_factor` must match the values passed to
1230 /// the most recent `prepare(...)` — a resize must go through the
1231 /// full layout path. Returns the same shape of [`PrepareResult`]
1232 /// for diagnostic continuity, with both deadlines re-computed
1233 /// from the cached signals: `next_layout_redraw_in` is `None` (we
1234 /// didn't re-evaluate), and `next_paint_redraw_in` is whatever
1235 /// the cached ops still report. The host owns the layout
1236 /// deadline across paint-only frames.
1237 pub fn repaint(
1238 &mut self,
1239 device: &wgpu::Device,
1240 queue: &wgpu::Queue,
1241 viewport: Rect,
1242 scale_factor: f32,
1243 ) -> PrepareResult {
1244 let mut timings = PrepareTimings::default();
1245
1246 self.text_paint.frame_begin();
1247 self.icon_paint.frame_begin();
1248 self.image_paint.frame_begin();
1249 self.surface_paint.frame_begin();
1250 self.scene_paint.frame_begin();
1251 let pipelines = &self.pipelines;
1252 let backdrop_shaders = &self.backdrop_shaders;
1253 let mut recorder = PaintRecorder {
1254 text: &mut self.text_paint,
1255 icons: &mut self.icon_paint,
1256 images: &mut self.image_paint,
1257 surfaces: &mut self.surface_paint,
1258 scenes: &mut self.scene_paint,
1259 device,
1260 queue,
1261 };
1262 self.core.prepare_paint_cached(
1263 |shader| pipelines.contains_key(shader),
1264 |shader| match shader {
1265 ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
1266 ShaderHandle::Stock(_) => false,
1267 },
1268 &mut recorder,
1269 scale_factor,
1270 &mut timings,
1271 );
1272
1273 // Same GPU-upload block as prepare(); time advances even though
1274 // ops are unchanged so time-driven shaders animate.
1275 {
1276 damascene_core::profile_span!("repaint::gpu_upload");
1277 let t_paint_end = Instant::now();
1278 if self.core.quad_scratch.len() > self.instance_capacity {
1279 let new_cap = self.core.quad_scratch.len().next_power_of_two();
1280 self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
1281 label: Some("damascene_wgpu::instance_buf (resized)"),
1282 size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
1283 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1284 mapped_at_creation: false,
1285 });
1286 self.instance_capacity = new_cap;
1287 }
1288 if !self.core.quad_scratch.is_empty() {
1289 queue.write_buffer(
1290 &self.instance_buf,
1291 0,
1292 bytemuck::cast_slice(&self.core.quad_scratch),
1293 );
1294 }
1295 self.text_paint.flush(device, queue);
1296 self.icon_paint.flush(device, queue);
1297 self.image_paint.flush(device, queue);
1298 self.surface_paint.flush(device, queue);
1299 self.scene_paint.flush(device, queue);
1300 let time = match self.core.ui_state().animation_mode() {
1301 AnimationMode::Settled => 0.0,
1302 AnimationMode::Live => (Instant::now() - self.start_time).as_secs_f32(),
1303 };
1304 let frame = FrameUniforms {
1305 viewport: [viewport.w, viewport.h],
1306 time,
1307 scale_factor,
1308 white_scale: self.white_scale,
1309 headroom: self.headroom,
1310 ref_nits: self.ref_nits,
1311 _reserved: 0.0,
1312 };
1313 queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
1314 timings.gpu_upload = Instant::now() - t_paint_end;
1315 }
1316
1317 // Re-evaluate the paint lane against the cached ops so the host
1318 // can re-arm the deadline. Cheap (one scan over already-resolved
1319 // ops). The layout lane is left as `None`: we didn't re-run
1320 // `prepare_layout`, so we have no fresh signal to report — the
1321 // host's previously-set layout deadline still stands.
1322 let time_shaders = &self.time_shaders;
1323 let next_paint_redraw_in = self.core.scan_continuous_shaders(|handle| match handle {
1324 ShaderHandle::Custom(name) => time_shaders.contains(name),
1325 ShaderHandle::Stock(_) => false,
1326 });
1327 PrepareResult {
1328 needs_redraw: next_paint_redraw_in.is_some(),
1329 next_redraw_in: next_paint_redraw_in,
1330 next_layout_redraw_in: None,
1331 next_paint_redraw_in,
1332 timings,
1333 }
1334 }
1335
1336 // ---- Input plumbing ----
1337 //
1338 // The host (winit-side) calls these from its event loop.
1339 // Coordinates are **logical pixels** — divide winit's physical
1340 // PhysicalPosition by the window scale factor before handing them in.
1341
1342 /// Update pointer position and recompute the hovered key.
1343 /// Returns the new hovered key, if any (host can use it for cursor
1344 /// styling or to decide whether to call `request_redraw`).
1345 /// Pointer moved to `p.x, p.y` (logical px). Returns the events to
1346 /// dispatch via `App::on_event` plus a `needs_redraw` flag — see
1347 /// [`PointerMove`] for why hosts must gate `request_redraw` on
1348 /// the flag. The hovered node is updated on `ui_state().hovered`
1349 /// regardless. Mouse-only hosts can construct `p` via
1350 /// [`Pointer::moving`].
1351 pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
1352 self.core.pointer_moved(p)
1353 }
1354
1355 /// Pointer left the window — clear hover/press. Returns a
1356 /// `PointerLeave` event for the previously hovered target (when
1357 /// there was one); hosts should route the events through
1358 /// `App::on_event` like the other pointer entry points.
1359 pub fn pointer_left(&mut self) -> Vec<damascene_core::UiEvent> {
1360 self.core.pointer_left()
1361 }
1362
1363 /// File is being dragged over the window. Hosts call this from
1364 /// `winit::WindowEvent::HoveredFile` (one call per file). Returns
1365 /// the `FileHovered` event routed to the keyed leaf at the cursor
1366 /// (or window-level if outside any keyed surface).
1367 pub fn file_hovered(
1368 &mut self,
1369 path: std::path::PathBuf,
1370 x: f32,
1371 y: f32,
1372 ) -> Vec<damascene_core::UiEvent> {
1373 self.core.file_hovered(path, x, y)
1374 }
1375
1376 /// File hover ended without a drop — hosts call this from
1377 /// `winit::WindowEvent::HoveredFileCancelled`. Window-level event
1378 /// (not routed); apps clear any drop-zone affordance.
1379 pub fn file_hover_cancelled(&mut self) -> Vec<damascene_core::UiEvent> {
1380 self.core.file_hover_cancelled()
1381 }
1382
1383 /// File was dropped on the window. Hosts call this from
1384 /// `winit::WindowEvent::DroppedFile` (one call per file).
1385 pub fn file_dropped(
1386 &mut self,
1387 path: std::path::PathBuf,
1388 x: f32,
1389 y: f32,
1390 ) -> Vec<damascene_core::UiEvent> {
1391 self.core.file_dropped(path, x, y)
1392 }
1393
1394 /// Whether a primary press at `(x, y)` (logical px) would land
1395 /// on a node that opted into `capture_keys` — the marker the
1396 /// library uses for text-input-style widgets. Hosts query this
1397 /// from a DOM pointerdown handler to decide whether to focus
1398 /// a hidden textarea (so the soft keyboard can open in the
1399 /// user-gesture context). See
1400 /// [`RunnerCore::would_press_focus_text_input`] for details.
1401 pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
1402 self.core.would_press_focus_text_input(x, y)
1403 }
1404
1405 /// Whether the currently focused node is a text-input-style
1406 /// widget (i.e. has `capture_keys` set). Hosts mirror this each
1407 /// frame into platform affordances such as the on-screen
1408 /// keyboard or IME compose-window placement.
1409 pub fn focused_captures_keys(&self) -> bool {
1410 self.core.focused_captures_keys()
1411 }
1412
1413 /// Pointer pressed at `p.x, p.y` (logical px) for `p.button`. For
1414 /// `Primary`, records the pressed key for press-visual feedback,
1415 /// updates focus, and returns a `PointerDown` event so widgets that
1416 /// need to react at down-time (text input selection anchor,
1417 /// draggable handles) can do so. For `Secondary` / `Middle`, records
1418 /// on a side channel and returns `None`. The actual click event
1419 /// fires on `pointer_up`. Mouse-only hosts can construct `p` via
1420 /// [`Pointer::mouse`].
1421 pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
1422 self.core.pointer_down(p)
1423 }
1424
1425 /// Replace the tracked modifier mask. Hosts call this from their
1426 /// platform's "modifiers changed" hook so subsequent pointer
1427 /// events (PointerDown, Drag, Click, …) stamp the current mask
1428 /// into `UiEvent.modifiers`.
1429 pub fn set_modifiers(&mut self, modifiers: KeyModifiers) {
1430 self.core.ui_state.set_modifiers(modifiers);
1431 }
1432
1433 /// Pointer released at `p.x, p.y` for `p.button`. Returns the
1434 /// events the host should dispatch in order: for `Primary`, always
1435 /// a `PointerUp` (when there was a corresponding down) followed
1436 /// by an optional `Click` (when the up landed on the down's
1437 /// node). For `Secondary` / `Middle`, an optional `SecondaryClick`
1438 /// / `MiddleClick` on the same-node match. Mouse-only hosts can
1439 /// construct `p` via [`Pointer::mouse`].
1440 pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1441 self.core.pointer_up(p)
1442 }
1443
1444 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1445 self.core.key_down(key, modifiers, repeat)
1446 }
1447
1448 /// Forward an OS-composed text-input string (winit's keyboard event
1449 /// `.text` field, or an `Ime::Commit`) to the focused element as a
1450 /// `TextInput` event.
1451 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1452 self.core.text_input(text)
1453 }
1454
1455 /// Replace the hotkey registry. Call once per frame, after `app.build()`,
1456 /// passing `app.hotkeys()` so chords stay in sync with state.
1457 ///
1458 /// The registry is scoped to this `Runner` — in a multi-window
1459 /// host (one `Runner` per window), pass each window only its own
1460 /// list and feed each window's key events only to its own
1461 /// `Runner`; chords then fire per focused window. See
1462 /// `damascene_core::App::hotkeys` for the full convention.
1463 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1464 self.core.set_hotkeys(hotkeys);
1465 }
1466
1467 /// Push the app's current selection to the runtime so the painter
1468 /// can draw highlight bands. Hosts call this once per frame
1469 /// alongside [`Self::set_hotkeys`].
1470 pub fn set_selection(&mut self, selection: damascene_core::selection::Selection) {
1471 self.core.set_selection(selection);
1472 }
1473
1474 /// Resolve the runtime's current selection to a text payload from
1475 /// the most recently laid-out tree. See
1476 /// [`RunnerCore::selected_text`] — virtual-list rows are realized
1477 /// during layout, so a freshly built app tree would miss them and
1478 /// a `Ctrl+C` lookup that walked it would silently come back empty.
1479 pub fn selected_text(&self) -> Option<String> {
1480 self.core.selected_text()
1481 }
1482
1483 /// Resolve an explicit [`damascene_core::selection::Selection`] against
1484 /// the last laid-out tree. See [`RunnerCore::selected_text_for`].
1485 pub fn selected_text_for(
1486 &self,
1487 selection: &damascene_core::selection::Selection,
1488 ) -> Option<String> {
1489 self.core.selected_text_for(selection)
1490 }
1491
1492 /// Queue toast specs onto the runtime's toast stack. Hosts call
1493 /// this once per frame with `app.drain_toasts()`. Each spec is
1494 /// stamped with a monotonic id and an `expires_at` deadline
1495 /// (`now + ttl`); the next `prepare` call drops expired entries
1496 /// and synthesizes a `toast_stack` floating layer over the rest.
1497 pub fn push_toasts(&mut self, specs: Vec<damascene_core::toast::ToastSpec>) {
1498 self.core.push_toasts(specs);
1499 }
1500
1501 /// Programmatically dismiss a toast by id. Useful for cancelling
1502 /// long-TTL toasts when an external condition resolves (e.g.,
1503 /// "reconnecting…" turning into "connected").
1504 pub fn dismiss_toast(&mut self, id: u64) {
1505 self.core.dismiss_toast(id);
1506 }
1507
1508 /// Queue programmatic focus requests by widget key. Hosts call
1509 /// this once per frame with `app.drain_focus_requests()`. Each
1510 /// key is resolved during the next `prepare` against the rebuilt
1511 /// focus order; unmatched keys drop silently.
1512 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1513 self.core.push_focus_requests(keys);
1514 }
1515
1516 /// Queue programmatic scroll-to-row requests targeting virtual
1517 /// lists by key. Hosts call this once per frame with
1518 /// `app.drain_scroll_requests()`. Each request is consumed during
1519 /// the next `prepare` by the layout pass for the matching list,
1520 /// where viewport height and row heights are known. Unmatched
1521 /// list keys and out-of-range row indices drop silently.
1522 pub fn push_scroll_requests(&mut self, requests: Vec<damascene_core::scroll::ScrollRequest>) {
1523 self.core.push_scroll_requests(requests);
1524 }
1525
1526 pub fn push_viewport_requests(
1527 &mut self,
1528 requests: Vec<damascene_core::viewport::ViewportRequest>,
1529 ) {
1530 self.core.push_viewport_requests(requests);
1531 }
1532
1533 /// Switch animation pacing. Default is [`AnimationMode::Live`].
1534 /// Headless render binaries should call this with
1535 /// [`AnimationMode::Settled`] so a single-frame snapshot reflects
1536 /// the post-animation visual without depending on integrator timing.
1537 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1538 self.core.set_animation_mode(mode);
1539 }
1540
1541 /// Apply a wheel delta in **logical** pixels at `(x, y)`. Routes to
1542 /// the deepest scrollable container under the cursor in the last
1543 /// laid-out tree. Returns `true` if the event landed on a scrollable
1544 /// (host should `request_redraw` so the next frame applies the new
1545 /// offset).
1546 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1547 self.core.pointer_wheel(x, y, dy)
1548 }
1549
1550 /// Build a routed wheel event for the keyed target under `(x, y)`.
1551 ///
1552 /// Dispatch this before [`Self::pointer_wheel`]; if the app
1553 /// consumes the event, skip the fallback scroll call.
1554 pub fn pointer_wheel_event(
1555 &mut self,
1556 x: f32,
1557 y: f32,
1558 dx: f32,
1559 dy: f32,
1560 ) -> Option<damascene_core::UiEvent> {
1561 self.core.pointer_wheel_event(x, y, dx, dy)
1562 }
1563
1564 /// Drain time-driven input events whose deadline has passed (touch
1565 /// long-press today; later: hold-to-repeat, etc.). Hosts call this
1566 /// once per frame before dispatching pointer events. `now` is
1567 /// `web_time::Instant` rather than `std::time::Instant` so the
1568 /// signature compiles on wasm32 — `web_time` aliases to std on
1569 /// native, so existing native callers passing `Instant::now()`
1570 /// from std still work. See [`damascene_core::RunnerCore::poll_input`].
1571 pub fn poll_input(&mut self, now: web_time::Instant) -> Vec<damascene_core::UiEvent> {
1572 self.core.poll_input(now)
1573 }
1574
1575 /// Record draws into the host-managed render pass. Call after
1576 /// [`Self::prepare`]. Paint order follows the draw-op stream.
1577 ///
1578 /// **No backdrop sampling.** This entry point cannot honor pass
1579 /// boundaries (the host owns the pass lifetime), so any
1580 /// `BackdropSnapshot` items in the paint stream are no-ops and any
1581 /// shader bound with `samples_backdrop=true` reads an undefined
1582 /// backdrop binding. Use [`Self::render`] for backdrop-aware
1583 /// rendering.
1584 ///
1585 /// **3D scenes need the pre-pass.** `Scene3D` paint items
1586 /// composite from offscreen targets that must be rendered before
1587 /// the host's pass begins — call [`Self::encode_scene_prepass`] on
1588 /// the encoder first, or every scene in the frame samples a
1589 /// never-rendered target and composites blank.
1590 pub fn draw<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>) {
1591 self.draw_items(pass, &self.core.paint_items);
1592 }
1593
1594 /// Encode the offscreen pre-pass for any 3D scenes in this frame's
1595 /// paint stream: each `Scene3D` renders into its own offscreen
1596 /// target, and label-bearing scenes capture depth for next frame's
1597 /// label occlusion. No-op when the frame has no scenes.
1598 ///
1599 /// [`Self::render`] calls this automatically. Hosts using
1600 /// [`Self::draw`] must call it on their encoder after
1601 /// [`Self::prepare`] and *before* beginning the render pass that
1602 /// `draw` records into.
1603 pub fn encode_scene_prepass(
1604 &mut self,
1605 device: &wgpu::Device,
1606 encoder: &mut wgpu::CommandEncoder,
1607 ) {
1608 if self.scene_paint.has_runs() {
1609 self.scene_paint.encode_offscreen(encoder);
1610 // Capture each label-bearing scene's depth into its read-back
1611 // buffer (the depth is still alive from the pass above). The
1612 // map + CPU read happens next frame in `prepare`.
1613 self.scene_paint.encode_depth_capture(device, encoder);
1614 }
1615 }
1616
1617 /// Record draws into a host-supplied encoder, owning pass
1618 /// lifetimes ourselves so backdrop-sampling shaders can sample a
1619 /// snapshot of Pass A's content.
1620 ///
1621 /// The host hands us:
1622 /// - the encoder (we record into it),
1623 /// - the color target's `wgpu::Texture` (used as `copy_src` when
1624 /// we snapshot it; must include `COPY_SRC` in its usage flags),
1625 /// - the corresponding `wgpu::TextureView` (we attach it to every
1626 /// render pass we begin), and
1627 /// - the `LoadOp` to use on the *first* pass — `Clear(color)` to
1628 /// clear behind us, `Load` to composite onto whatever was
1629 /// already in the target.
1630 ///
1631 /// Multi-pass schedule when the paint stream contains a
1632 /// `BackdropSnapshot`:
1633 ///
1634 /// 1. Pass A — every paint item before the snapshot, with the
1635 /// caller-supplied `LoadOp`.
1636 /// 2. `copy_texture_to_texture` — target → snapshot.
1637 /// 3. Pass B — paint items from the snapshot onward, with
1638 /// `LoadOp::Load` so Pass A's pixels remain underneath.
1639 ///
1640 /// Without a snapshot, this collapses to a single pass and is
1641 /// equivalent to [`Self::draw`] called inside a host-managed
1642 /// pass with the same `LoadOp`.
1643 pub fn render(
1644 &mut self,
1645 device: &wgpu::Device,
1646 encoder: &mut wgpu::CommandEncoder,
1647 target_tex: &wgpu::Texture,
1648 target_view: &wgpu::TextureView,
1649 msaa_view: Option<&wgpu::TextureView>,
1650 load_op: wgpu::LoadOp<wgpu::Color>,
1651 ) {
1652 // When MSAA is in use, the actual color attachment is the
1653 // multisampled view and `target_view` becomes its resolve
1654 // target. `target_tex` is always the resolved (single-sample)
1655 // texture, so the snapshot copy below works whether MSAA is on
1656 // or not — the resolve happens at end-of-Pass-A.
1657 let attachment_view = msaa_view.unwrap_or(target_view);
1658 let resolve_target = msaa_view.map(|_| target_view);
1659
1660 // Phase 1: render every recorded 3D scene into its own offscreen
1661 // target. Passes can't nest, so this is encoded on `encoder` ahead
1662 // of the main composite pass (same discipline as BackdropSnapshot).
1663 // The `PaintItem::Scene3D` arm below then composites the resolved
1664 // textures into the main pass.
1665 self.encode_scene_prepass(device, encoder);
1666
1667 // Locate the (at most one) snapshot boundary.
1668 let split_at = self
1669 .core
1670 .paint_items
1671 .iter()
1672 .position(|p| matches!(p, PaintItem::BackdropSnapshot));
1673
1674 if let Some(idx) = split_at {
1675 self.ensure_snapshot(device, target_tex);
1676 // Pass A
1677 {
1678 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1679 label: Some("damascene_wgpu::pass_a"),
1680 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1681 view: attachment_view,
1682 resolve_target,
1683 depth_slice: None,
1684 ops: wgpu::Operations {
1685 load: load_op,
1686 store: wgpu::StoreOp::Store,
1687 },
1688 })],
1689 depth_stencil_attachment: None,
1690 timestamp_writes: None,
1691 occlusion_query_set: None,
1692 multiview_mask: None,
1693 });
1694 self.draw_items(&mut pass, &self.core.paint_items[..idx]);
1695 }
1696 // Snapshot copy. Target must support COPY_SRC; snapshot
1697 // texture (created in `ensure_snapshot`) supports COPY_DST
1698 // + TEXTURE_BINDING.
1699 let snapshot = self.snapshot.as_ref().expect("snapshot ensured");
1700 encoder.copy_texture_to_texture(
1701 wgpu::TexelCopyTextureInfo {
1702 texture: target_tex,
1703 mip_level: 0,
1704 origin: wgpu::Origin3d::ZERO,
1705 aspect: wgpu::TextureAspect::All,
1706 },
1707 wgpu::TexelCopyTextureInfo {
1708 texture: &snapshot.texture,
1709 mip_level: 0,
1710 origin: wgpu::Origin3d::ZERO,
1711 aspect: wgpu::TextureAspect::All,
1712 },
1713 wgpu::Extent3d {
1714 width: snapshot.extent.0,
1715 height: snapshot.extent.1,
1716 depth_or_array_layers: 1,
1717 },
1718 );
1719 // Pass B
1720 {
1721 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1722 label: Some("damascene_wgpu::pass_b"),
1723 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1724 view: attachment_view,
1725 resolve_target,
1726 depth_slice: None,
1727 ops: wgpu::Operations {
1728 load: wgpu::LoadOp::Load,
1729 store: wgpu::StoreOp::Store,
1730 },
1731 })],
1732 depth_stencil_attachment: None,
1733 timestamp_writes: None,
1734 occlusion_query_set: None,
1735 multiview_mask: None,
1736 });
1737 // Skip the snapshot item itself; it's a marker, not a draw.
1738 self.draw_items(&mut pass, &self.core.paint_items[idx + 1..]);
1739 }
1740 } else {
1741 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1742 label: Some("damascene_wgpu::pass"),
1743 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1744 view: attachment_view,
1745 resolve_target,
1746 depth_slice: None,
1747 ops: wgpu::Operations {
1748 load: load_op,
1749 store: wgpu::StoreOp::Store,
1750 },
1751 })],
1752 depth_stencil_attachment: None,
1753 timestamp_writes: None,
1754 occlusion_query_set: None,
1755 multiview_mask: None,
1756 });
1757 self.draw_items(&mut pass, &self.core.paint_items);
1758 }
1759 }
1760
1761 /// (Re)allocate the snapshot texture to match `target_tex`'s
1762 /// extent + format. Idempotent when the size matches; rebuilds the
1763 /// `backdrop_bind_group` whenever the snapshot is recreated.
1764 fn ensure_snapshot(&mut self, device: &wgpu::Device, target_tex: &wgpu::Texture) {
1765 let extent = target_tex.size();
1766 let want = (extent.width, extent.height);
1767 if let Some(s) = &self.snapshot
1768 && s.extent == want
1769 {
1770 return;
1771 }
1772 let texture = device.create_texture(&wgpu::TextureDescriptor {
1773 label: Some("damascene_wgpu::backdrop_snapshot"),
1774 size: wgpu::Extent3d {
1775 width: want.0,
1776 height: want.1,
1777 depth_or_array_layers: 1,
1778 },
1779 mip_level_count: 1,
1780 sample_count: 1,
1781 dimension: wgpu::TextureDimension::D2,
1782 format: self.target_format,
1783 usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
1784 view_formats: &[],
1785 });
1786 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1787 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1788 label: Some("damascene_wgpu::backdrop_bind_group"),
1789 layout: &self.backdrop_bind_layout,
1790 entries: &[
1791 wgpu::BindGroupEntry {
1792 binding: 0,
1793 resource: wgpu::BindingResource::TextureView(&view),
1794 },
1795 wgpu::BindGroupEntry {
1796 binding: 1,
1797 resource: wgpu::BindingResource::Sampler(&self.backdrop_sampler),
1798 },
1799 ],
1800 });
1801 self.snapshot = Some(SnapshotTexture {
1802 texture,
1803 extent: want,
1804 });
1805 self.backdrop_bind_group = Some(bind_group);
1806 }
1807
1808 /// Walk a slice of `PaintItem`s into the given pass. Helper shared
1809 /// by [`Self::draw`] and [`Self::render`]. `BackdropSnapshot`
1810 /// items are no-ops here; `render()` handles them by splitting
1811 /// the slice before passing to this helper.
1812 fn draw_items<'pass>(
1813 &'pass self,
1814 pass: &mut wgpu::RenderPass<'pass>,
1815 items: &'pass [PaintItem],
1816 ) {
1817 let full = PhysicalScissor {
1818 x: 0,
1819 y: 0,
1820 w: self.core.viewport_px.0,
1821 h: self.core.viewport_px.1,
1822 };
1823 for item in items {
1824 match *item {
1825 PaintItem::QuadRun(index) => {
1826 let run = &self.core.runs[index];
1827 set_scissor(pass, run.scissor, full);
1828 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1829 let is_backdrop_shader = matches!(
1830 run.handle,
1831 ShaderHandle::Custom(name) if self.backdrop_shaders.contains(name)
1832 );
1833 if is_backdrop_shader && let Some(bg) = &self.backdrop_bind_group {
1834 pass.set_bind_group(1, bg, &[]);
1835 }
1836 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1837 pass.set_vertex_buffer(1, self.instance_buf.slice(..));
1838 let pipeline = self
1839 .pipelines
1840 .get(&run.handle)
1841 .expect("run handle has no pipeline (bug in prepare)");
1842 pass.set_pipeline(pipeline);
1843 pass.draw(0..4, run.first..run.first + run.count);
1844 }
1845 PaintItem::Text(index) => {
1846 let run = self.text_paint.run(index);
1847 set_scissor(pass, run.scissor, full);
1848 pass.set_pipeline(self.text_paint.pipeline_for(run.kind));
1849 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1850 // Highlight runs use a frame-uniform-only pipeline.
1851 // Glyph kinds bind the active atlas page at group 1.
1852 if !matches!(run.kind, crate::text::TextRunKind::Highlight) {
1853 pass.set_bind_group(
1854 1,
1855 self.text_paint.page_bind_group(run.kind, run.page),
1856 &[],
1857 );
1858 }
1859 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1860 pass.set_vertex_buffer(1, self.text_paint.instance_buf_for(run.kind).slice(..));
1861 pass.draw(0..4, run.first..run.first + run.count);
1862 }
1863 PaintItem::IconRun(index) | PaintItem::Vector(index) => {
1864 // `PaintItem::Vector` is structurally identical to
1865 // `PaintItem::IconRun` — both index into the same
1866 // `IconPaint::runs` Vec since `record_vector`
1867 // appends there too. The variant is kept distinct
1868 // for paint-stream provenance (icon vs app vector)
1869 // but the dispatch is the same.
1870 let run = self.icon_paint.run(index);
1871 set_scissor(pass, run.scissor, full);
1872 match run.kind {
1873 IconRunKind::Tess => {
1874 pass.set_pipeline(self.icon_paint.tess_pipeline(run.material));
1875 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1876 pass.set_vertex_buffer(0, self.icon_paint.tess_vertex_buf().slice(..));
1877 pass.draw(run.first..run.first + run.count, 0..1);
1878 }
1879 IconRunKind::Msdf => {
1880 pass.set_pipeline(self.icon_paint.msdf_pipeline());
1881 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1882 pass.set_bind_group(
1883 1,
1884 self.icon_paint.msdf_page_bind_group(run.page),
1885 &[],
1886 );
1887 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1888 pass.set_vertex_buffer(
1889 1,
1890 self.icon_paint.msdf_instance_buf().slice(..),
1891 );
1892 pass.draw(0..4, run.first..run.first + run.count);
1893 }
1894 }
1895 }
1896 PaintItem::Image(index) => {
1897 let run = self.image_paint.run(index);
1898 set_scissor(pass, run.scissor, full);
1899 pass.set_pipeline(self.image_paint.pipeline());
1900 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1901 pass.set_bind_group(1, self.image_paint.bind_group_for_run(run), &[]);
1902 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1903 pass.set_vertex_buffer(1, self.image_paint.instance_buf().slice(..));
1904 pass.draw(0..4, run.first..run.first + run.count);
1905 }
1906 PaintItem::AppTexture(index) => {
1907 let run = self.surface_paint.run(index);
1908 set_scissor(pass, run.scissor, full);
1909 pass.set_pipeline(self.surface_paint.pipeline_for(run.alpha));
1910 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1911 pass.set_bind_group(1, self.surface_paint.bind_group_for_run(run), &[]);
1912 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1913 pass.set_vertex_buffer(1, self.surface_paint.instance_buf().slice(..));
1914 pass.draw(0..4, run.first..run.first + run.count);
1915 }
1916 PaintItem::Scene3D(index) => {
1917 // The scene already rendered + resolved offscreen in
1918 // phase 1; composite that texture over the rect via the
1919 // stock surface pipeline (premultiplied).
1920 let run = self.scene_paint.run(index);
1921 set_scissor(pass, run.scissor, full);
1922 pass.set_pipeline(self.scene_paint.composite_pipeline());
1923 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1924 pass.set_bind_group(1, self.scene_paint.composite_bind_group(run), &[]);
1925 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1926 pass.set_vertex_buffer(1, self.scene_paint.composite_instance_buf().slice(..));
1927 pass.draw(0..4, run.composite_instance..run.composite_instance + 1);
1928 }
1929 PaintItem::BackdropSnapshot => {
1930 // Marker only — `render()` splits the slice on
1931 // these and never includes one in a draw range.
1932 }
1933 }
1934 }
1935 }
1936}