aetna_wgpu/lib.rs
1//! `wgpu` backend for custom Aetna hosts.
2//!
3//! Most applications should implement `aetna_core::App` and run it
4//! through `aetna-winit-wgpu`. Use this crate directly when you are
5//! writing your own host, embedding Aetna 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//! `aetna_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 aetna_core::prelude::*;
24//! use aetna_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(&aetna_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 Aetna 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 aetna-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 surface;
74mod text;
75
76pub use crate::msaa::MsaaTarget;
77pub use crate::surface::{WgpuAppTexture, app_texture};
78
79use std::collections::{HashMap, HashSet};
80// `web_time::Instant` is API-identical to `std::time::Instant` on
81// native and uses `performance.now()` on wasm32 — std's `Instant::now()`
82// panics in the browser because there is no monotonic clock there.
83use web_time::Instant;
84
85use wgpu::util::DeviceExt;
86
87use aetna_core::event::{KeyChord, KeyModifiers, PointerButton, UiEvent, UiKey};
88use aetna_core::ir::TextAnchor;
89use aetna_core::paint::{IconRunKind, PhysicalScissor, QuadInstance};
90use aetna_core::runtime::{RecordedPaint, RunnerCore, TextRecorder};
91use aetna_core::shader::{ShaderHandle, StockShader, stock_wgsl};
92use aetna_core::state::{AnimationMode, UiState};
93use aetna_core::text::atlas::RunStyle;
94use aetna_core::theme::Theme;
95use aetna_core::tree::{Color, El, Rect, TextWrap};
96use aetna_core::vector::IconMaterial;
97
98pub use aetna_core::paint::PaintItem;
99pub use aetna_core::runtime::{LayoutPrepared, PointerMove, PrepareResult, PrepareTimings};
100
101use crate::icon::IconPaint;
102use crate::image::ImagePaint;
103use crate::instance::set_scissor;
104use crate::pipeline::{FrameUniforms, build_quad_pipeline};
105use crate::surface::SurfacePaint;
106use crate::text::TextPaint;
107
108/// Initial size for the dynamic instance buffer (grows as needed).
109const INITIAL_INSTANCE_CAPACITY: usize = 256;
110
111/// Wgpu runtime owned by the host. One instance per surface/format.
112///
113/// All backend-agnostic state — interaction state, paint-stream scratch,
114/// per-stage layout/animation hooks — lives in `core: RunnerCore` and
115/// is shared with the vulkano backend. The fields below are wgpu-specific
116/// resources only.
117pub struct Runner {
118 target_format: wgpu::TextureFormat,
119 sample_count: u32,
120 /// Whether the adapter advertises `DownlevelFlags::MULTISAMPLED_SHADING`.
121 /// Threaded through to [`build_quad_pipeline`] so stock and custom
122 /// shaders that use `@interpolate(perspective, sample)` get
123 /// downlevelled (qualifier stripped) on backends that don't support
124 /// per-sample shading — notably WebGL2 and most browser WebGPU
125 /// adapters. See [`Self::with_caps`] for the host-side query.
126 per_sample_shading: bool,
127
128 // Shared resources.
129 pipeline_layout: wgpu::PipelineLayout,
130 /// Pipeline layout for `samples_backdrop` custom shaders — adds
131 /// `@group(1)` for the snapshot texture + sampler.
132 backdrop_pipeline_layout: wgpu::PipelineLayout,
133 quad_bind_group: wgpu::BindGroup,
134 backdrop_bind_layout: wgpu::BindGroupLayout,
135 backdrop_sampler: wgpu::Sampler,
136 frame_buf: wgpu::Buffer,
137 quad_vbo: wgpu::Buffer,
138 instance_buf: wgpu::Buffer,
139 instance_capacity: usize,
140
141 // One pipeline per registered shader (stock + custom).
142 pipelines: HashMap<ShaderHandle, wgpu::RenderPipeline>,
143 // Custom shader names registered with `samples_backdrop=true`. The
144 // paint scheduler queries this to insert pass boundaries before the
145 // first backdrop-sampling draw.
146 backdrop_shaders: HashSet<&'static str>,
147 // Custom shader names registered with `samples_time=true`. Mirrors
148 // `backdrop_shaders` but feeds `prepare_layout`'s continuous-redraw
149 // scan instead of the paint scheduler.
150 time_shaders: HashSet<&'static str>,
151
152 // stock::text resources — atlas, page textures, glyph instances.
153 text_paint: TextPaint,
154 // stock::icon_line resources — vector icon stroke instances.
155 icon_paint: IconPaint,
156 // stock::image resources — per-image texture cache + instance buf.
157 image_paint: ImagePaint,
158 surface_paint: SurfacePaint,
159
160 /// Lazily-allocated snapshot of the color target, sized to match
161 /// the current target on each `render()`. Backdrop-sampling
162 /// shaders read this via `@group(1)` after Pass A.
163 snapshot: Option<SnapshotTexture>,
164 /// Bind group binding the snapshot view + sampler. Rebuilt each
165 /// time the snapshot texture is reallocated.
166 backdrop_bind_group: Option<wgpu::BindGroup>,
167
168 /// Wall-clock origin for the `time` field in `FrameUniforms`.
169 /// `prepare()` writes `(now - start_time).as_secs_f32()`.
170 start_time: Instant,
171
172 // Backend-agnostic state shared with aetna-vulkano: interaction
173 // state, paint-stream scratch (quad_scratch / runs / paint_items),
174 // viewport_px, last_tree, the 13 input plumbing methods.
175 core: RunnerCore,
176}
177
178struct SnapshotTexture {
179 texture: wgpu::Texture,
180 extent: (u32, u32),
181}
182
183struct PaintRecorder<'a> {
184 text: &'a mut TextPaint,
185 icons: &'a mut IconPaint,
186 images: &'a mut ImagePaint,
187 surfaces: &'a mut SurfacePaint,
188 device: &'a wgpu::Device,
189 queue: &'a wgpu::Queue,
190}
191
192impl TextRecorder for PaintRecorder<'_> {
193 fn record(
194 &mut self,
195 rect: Rect,
196 scissor: Option<PhysicalScissor>,
197 style: &aetna_core::text::atlas::RunStyle,
198 text: &str,
199 size: f32,
200 line_height: f32,
201 wrap: TextWrap,
202 anchor: TextAnchor,
203 scale_factor: f32,
204 ) -> std::ops::Range<usize> {
205 self.text.record(
206 rect,
207 scissor,
208 style,
209 text,
210 size,
211 line_height,
212 wrap,
213 anchor,
214 scale_factor,
215 )
216 }
217
218 fn record_runs(
219 &mut self,
220 rect: Rect,
221 scissor: Option<PhysicalScissor>,
222 runs: &[(String, RunStyle)],
223 size: f32,
224 line_height: f32,
225 wrap: TextWrap,
226 anchor: TextAnchor,
227 scale_factor: f32,
228 ) -> std::ops::Range<usize> {
229 self.text.record_runs(
230 rect,
231 scissor,
232 runs,
233 size,
234 line_height,
235 wrap,
236 anchor,
237 scale_factor,
238 )
239 }
240
241 fn record_icon(
242 &mut self,
243 rect: Rect,
244 scissor: Option<PhysicalScissor>,
245 source: &aetna_core::svg_icon::IconSource,
246 color: Color,
247 _size: f32,
248 stroke_width: f32,
249 _scale_factor: f32,
250 ) -> RecordedPaint {
251 RecordedPaint::Icon(
252 self.icons
253 .record(rect, scissor, source, color, stroke_width),
254 )
255 }
256
257 fn record_image(
258 &mut self,
259 rect: Rect,
260 scissor: Option<PhysicalScissor>,
261 image: &aetna_core::image::Image,
262 tint: Option<Color>,
263 radius: aetna_core::tree::Corners,
264 _fit: aetna_core::image::ImageFit,
265 _scale_factor: f32,
266 ) -> std::ops::Range<usize> {
267 self.images
268 .record(self.device, self.queue, rect, scissor, image, tint, radius)
269 }
270
271 fn record_app_texture(
272 &mut self,
273 rect: Rect,
274 scissor: Option<PhysicalScissor>,
275 texture: &aetna_core::surface::AppTexture,
276 alpha: aetna_core::surface::SurfaceAlpha,
277 transform: aetna_core::affine::Affine2,
278 _scale_factor: f32,
279 ) -> std::ops::Range<usize> {
280 self.surfaces
281 .record(self.device, rect, scissor, texture, alpha, transform)
282 }
283
284 fn record_vector(
285 &mut self,
286 rect: Rect,
287 scissor: Option<PhysicalScissor>,
288 asset: &aetna_core::vector::VectorAsset,
289 render_mode: aetna_core::vector::VectorRenderMode,
290 _scale_factor: f32,
291 ) -> std::ops::Range<usize> {
292 self.icons.record_vector(rect, scissor, asset, render_mode)
293 }
294}
295
296impl Runner {
297 /// Create a runner for the given target color format. The host
298 /// passes its swapchain/render-target format here so pipelines and
299 /// the glyph atlas are built compatible.
300 pub fn new(
301 device: &wgpu::Device,
302 queue: &wgpu::Queue,
303 target_format: wgpu::TextureFormat,
304 ) -> Self {
305 Self::with_sample_count(device, queue, target_format, 1)
306 }
307
308 /// Like [`Self::new`], but builds all pipelines with `sample_count`
309 /// MSAA samples. The host must provide a matching multisampled
310 /// render target and a single-sample resolve target. `sample_count`
311 /// of 1 is the non-MSAA default.
312 ///
313 /// Defaults `per_sample_shading` to `true` — appropriate for native
314 /// adapters, where `DownlevelFlags::MULTISAMPLED_SHADING` is the norm.
315 /// Web/WebGL2 hosts must instead route through [`Self::with_caps`]
316 /// and pass the actual cap from the adapter, otherwise stock
317 /// pipelines fail naga validation on shader-module creation.
318 pub fn with_sample_count(
319 device: &wgpu::Device,
320 queue: &wgpu::Queue,
321 target_format: wgpu::TextureFormat,
322 sample_count: u32,
323 ) -> Self {
324 Self::with_caps(device, queue, target_format, sample_count, true)
325 }
326
327 /// Like [`Self::with_sample_count`], but with the `per_sample_shading`
328 /// downlevel cap supplied explicitly. Hosts that target backends
329 /// without `DownlevelFlags::MULTISAMPLED_SHADING` (WebGL2, most
330 /// browser WebGPU) read the flag off the adapter and pass it here:
331 ///
332 /// ```ignore
333 /// let caps = adapter.get_downlevel_capabilities();
334 /// let pss = caps.flags.contains(wgpu::DownlevelFlags::MULTISAMPLED_SHADING);
335 /// Runner::with_caps(&device, &queue, format, sample_count, pss)
336 /// ```
337 ///
338 /// When `false`, every pipeline (stock and later-registered custom)
339 /// has `@interpolate(perspective, sample)` rewritten to
340 /// `@interpolate(perspective)` before WGSL compilation. The shader
341 /// then interpolates at pixel centre instead of per MSAA sample —
342 /// MSAA coverage still works at `sample_count > 1`; only the
343 /// per-sub-sample brightness pass is skipped, slightly thickening
344 /// the AA band on curved SDF edges.
345 pub fn with_caps(
346 device: &wgpu::Device,
347 _queue: &wgpu::Queue,
348 target_format: wgpu::TextureFormat,
349 sample_count: u32,
350 per_sample_shading: bool,
351 ) -> Self {
352 // ---- Shared resources ----
353 let frame_buf = device.create_buffer(&wgpu::BufferDescriptor {
354 label: Some("aetna_wgpu::frame_uniforms"),
355 size: std::mem::size_of::<FrameUniforms>() as u64,
356 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
357 mapped_at_creation: false,
358 });
359
360 let frame_bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
361 label: Some("aetna_wgpu::frame_bind_layout"),
362 entries: &[wgpu::BindGroupLayoutEntry {
363 binding: 0,
364 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
365 ty: wgpu::BindingType::Buffer {
366 ty: wgpu::BufferBindingType::Uniform,
367 has_dynamic_offset: false,
368 min_binding_size: None,
369 },
370 count: None,
371 }],
372 });
373
374 let quad_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
375 label: Some("aetna_wgpu::frame_bind_group"),
376 layout: &frame_bind_layout,
377 entries: &[wgpu::BindGroupEntry {
378 binding: 0,
379 resource: frame_buf.as_entire_binding(),
380 }],
381 });
382
383 let quad_vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
384 label: Some("aetna_wgpu::quad_vbo"),
385 // Triangle strip: 4 corners, uv 0..1.
386 contents: bytemuck::cast_slice::<f32, u8>(&[0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
387 usage: wgpu::BufferUsages::VERTEX,
388 });
389
390 let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
391 label: Some("aetna_wgpu::instance_buf"),
392 size: (INITIAL_INSTANCE_CAPACITY * std::mem::size_of::<QuadInstance>()) as u64,
393 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
394 mapped_at_creation: false,
395 });
396
397 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
398 label: Some("aetna_wgpu::pipeline_layout"),
399 bind_group_layouts: &[Some(&frame_bind_layout)],
400 immediate_size: 0,
401 });
402
403 // ---- Backdrop sampling resources ----
404 //
405 // Custom shaders that opt into backdrop sampling (registered
406 // via `register_shader_with(..samples_backdrop=true)`) get a
407 // pipeline layout with `@group(1)` for the snapshot texture
408 // and sampler. The bind group is rebuilt whenever the
409 // snapshot is (re)allocated.
410 let backdrop_bind_layout =
411 device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
412 label: Some("aetna_wgpu::backdrop_bind_layout"),
413 entries: &[
414 wgpu::BindGroupLayoutEntry {
415 binding: 0,
416 visibility: wgpu::ShaderStages::FRAGMENT,
417 ty: wgpu::BindingType::Texture {
418 sample_type: wgpu::TextureSampleType::Float { filterable: true },
419 view_dimension: wgpu::TextureViewDimension::D2,
420 multisampled: false,
421 },
422 count: None,
423 },
424 wgpu::BindGroupLayoutEntry {
425 binding: 1,
426 visibility: wgpu::ShaderStages::FRAGMENT,
427 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
428 count: None,
429 },
430 ],
431 });
432 let backdrop_pipeline_layout =
433 device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
434 label: Some("aetna_wgpu::backdrop_pipeline_layout"),
435 bind_group_layouts: &[Some(&frame_bind_layout), Some(&backdrop_bind_layout)],
436 immediate_size: 0,
437 });
438 let backdrop_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
439 label: Some("aetna_wgpu::backdrop_sampler"),
440 address_mode_u: wgpu::AddressMode::ClampToEdge,
441 address_mode_v: wgpu::AddressMode::ClampToEdge,
442 address_mode_w: wgpu::AddressMode::ClampToEdge,
443 mag_filter: wgpu::FilterMode::Linear,
444 min_filter: wgpu::FilterMode::Linear,
445 mipmap_filter: wgpu::MipmapFilterMode::Nearest,
446 ..Default::default()
447 });
448
449 // Build stock rect-shaped pipelines up-front; custom shaders are
450 // added on demand by the host.
451 let mut pipelines = HashMap::new();
452 let rr_pipeline = build_quad_pipeline(
453 device,
454 &pipeline_layout,
455 target_format,
456 sample_count,
457 "stock::rounded_rect",
458 stock_wgsl::ROUNDED_RECT,
459 per_sample_shading,
460 );
461 pipelines.insert(ShaderHandle::Stock(StockShader::RoundedRect), rr_pipeline);
462
463 let spinner_pipeline = build_quad_pipeline(
464 device,
465 &pipeline_layout,
466 target_format,
467 sample_count,
468 "stock::spinner",
469 stock_wgsl::SPINNER,
470 per_sample_shading,
471 );
472 pipelines.insert(ShaderHandle::Stock(StockShader::Spinner), spinner_pipeline);
473
474 let skeleton_pipeline = build_quad_pipeline(
475 device,
476 &pipeline_layout,
477 target_format,
478 sample_count,
479 "stock::skeleton",
480 stock_wgsl::SKELETON,
481 per_sample_shading,
482 );
483 pipelines.insert(
484 ShaderHandle::Stock(StockShader::Skeleton),
485 skeleton_pipeline,
486 );
487
488 let progress_indeterminate_pipeline = build_quad_pipeline(
489 device,
490 &pipeline_layout,
491 target_format,
492 sample_count,
493 "stock::progress_indeterminate",
494 stock_wgsl::PROGRESS_INDETERMINATE,
495 per_sample_shading,
496 );
497 pipelines.insert(
498 ShaderHandle::Stock(StockShader::ProgressIndeterminate),
499 progress_indeterminate_pipeline,
500 );
501
502 // Text pipeline + atlas (replaces glyphon).
503 let text_paint = TextPaint::new(device, target_format, sample_count, &frame_bind_layout);
504 let icon_paint = IconPaint::new(device, target_format, sample_count, &frame_bind_layout);
505 let image_paint = ImagePaint::new(device, target_format, sample_count, &frame_bind_layout);
506 let surface_paint =
507 SurfacePaint::new(device, target_format, sample_count, &frame_bind_layout);
508
509 let mut core = RunnerCore::new();
510 core.quad_scratch = Vec::with_capacity(INITIAL_INSTANCE_CAPACITY);
511
512 Self {
513 target_format,
514 sample_count,
515 per_sample_shading,
516 pipeline_layout,
517 backdrop_pipeline_layout,
518 quad_bind_group,
519 backdrop_bind_layout,
520 backdrop_sampler,
521 frame_buf,
522 quad_vbo,
523 instance_buf,
524 instance_capacity: INITIAL_INSTANCE_CAPACITY,
525 pipelines,
526 backdrop_shaders: HashSet::new(),
527 time_shaders: HashSet::new(),
528 text_paint,
529 icon_paint,
530 image_paint,
531 surface_paint,
532 snapshot: None,
533 backdrop_bind_group: None,
534 start_time: Instant::now(),
535 core,
536 }
537 }
538
539 /// Tell the runner the swapchain texture size in physical pixels.
540 /// Call this once after `surface.configure(...)` and again on every
541 /// `WindowEvent::Resized`. The runner uses this as the canonical
542 /// `viewport_px` for scissor math; without it, the value is derived
543 /// from `viewport.w * scale_factor`, which can drift by one pixel
544 /// when `scale_factor` is fractional and trip wgpu's
545 /// `set_scissor_rect` validation.
546 pub fn set_surface_size(&mut self, width: u32, height: u32) {
547 self.core.set_surface_size(width, height);
548 }
549
550 /// Set the theme used to resolve implicit widget surfaces to shaders.
551 /// Pre-rasterize printable ASCII for the bundled default faces
552 /// (Inter Variable + JetBrains Mono Variable). Pays the ~40ms
553 /// one-time MSDF-generation cost up-front so the first frame that
554 /// introduces each character doesn't take a 20-30ms paint hit.
555 /// Hosts that interactively render UI text (the showcase, custom
556 /// apps, etc.) should call this once after constructing the
557 /// `Runner` and before the first frame; headless fixtures that
558 /// render only static content can skip it. MSDF keys are
559 /// size-independent so each character is rasterized exactly once
560 /// and reused for every size + weight afterwards.
561 pub fn warm_default_glyphs(&mut self) {
562 self.text_paint.warm_default_glyphs();
563 }
564
565 pub fn set_theme(&mut self, theme: Theme) {
566 self.icon_paint.set_material(theme.icon_material());
567 self.core.set_theme(theme);
568 }
569
570 pub fn theme(&self) -> &Theme {
571 self.core.theme()
572 }
573
574 /// Select the stock material used by the vector-icon painter.
575 /// Prefer [`Theme::with_icon_material`] for app-level routing; this
576 /// remains useful for low-level render fixtures.
577 pub fn set_icon_material(&mut self, material: IconMaterial) {
578 self.icon_paint.set_material(material);
579 }
580
581 pub fn icon_material(&self) -> IconMaterial {
582 self.icon_paint.material()
583 }
584
585 /// Register a custom shader. `name` is the same string passed to
586 /// `aetna_core::shader::ShaderBinding::custom`; nodes bound to it
587 /// via [`El::shader`](aetna_core::tree::El) paint through this
588 /// pipeline.
589 ///
590 /// The WGSL source must use the shared `(rect, vec_a, vec_b, vec_c)`
591 /// instance layout and the `FrameUniforms` bind group described in
592 /// the module docs. Compilation happens at register time — invalid
593 /// WGSL panics here, not mid-frame.
594 ///
595 /// Re-registering the same name replaces the previous pipeline
596 /// (useful for hot-reload during development).
597 pub fn register_shader(&mut self, device: &wgpu::Device, name: &'static str, wgsl: &str) {
598 self.register_shader_with(device, name, wgsl, false, false);
599 }
600
601 /// Register a custom shader, with opt-in flags for backdrop
602 /// sampling and time-driven motion.
603 ///
604 /// `samples_backdrop=true` schedules the shader's draws into
605 /// Pass B (after a snapshot of Pass A's rendered content) and
606 /// binds the snapshot texture as `@group(2) binding=0`
607 /// (`backdrop_tex`) plus a sampler at `binding=1`
608 /// (`backdrop_smp`). See `docs/SHADER_VISION.md` §"Backdrop
609 /// sampling architecture". Backdrop depth is capped at 1.
610 ///
611 /// `samples_time=true` declares that the shader's output depends
612 /// on `frame.time`. The runtime ORs this into
613 /// [`PrepareResult::needs_redraw`] for any frame that has at
614 /// least one node bound to the shader, so the host idle loop
615 /// keeps ticking without a per-El opt-in. Stock shaders self-
616 /// report through [`aetna_core::shader::StockShader::is_continuous`];
617 /// this flag is the same signal for app-registered WGSL.
618 pub fn register_shader_with(
619 &mut self,
620 device: &wgpu::Device,
621 name: &'static str,
622 wgsl: &str,
623 samples_backdrop: bool,
624 samples_time: bool,
625 ) {
626 let label = format!("custom::{name}");
627 let layout = if samples_backdrop {
628 &self.backdrop_pipeline_layout
629 } else {
630 &self.pipeline_layout
631 };
632 let pipeline = build_quad_pipeline(
633 device,
634 layout,
635 self.target_format,
636 self.sample_count,
637 &label,
638 wgsl,
639 self.per_sample_shading,
640 );
641 self.pipelines.insert(ShaderHandle::Custom(name), pipeline);
642 if samples_backdrop {
643 self.backdrop_shaders.insert(name);
644 } else {
645 self.backdrop_shaders.remove(name);
646 }
647 if samples_time {
648 self.time_shaders.insert(name);
649 } else {
650 self.time_shaders.remove(name);
651 }
652 }
653
654 /// Borrow the internal [`UiState`] — primarily for headless fixtures
655 /// that want to look up a node's rect after `prepare` (e.g., to
656 /// simulate a pointer at a specific button's center).
657 pub fn ui_state(&self) -> &UiState {
658 self.core.ui_state()
659 }
660
661 /// One-line diagnostic snapshot of interactive state — passes through
662 /// to [`UiState::debug_summary`]. Intended for per-frame logging
663 /// (e.g., `console.log` from the wasm host while debugging hover /
664 /// animation glitches).
665 pub fn debug_summary(&self) -> String {
666 self.core.debug_summary()
667 }
668
669 /// Return the most recently laid-out rectangle for a keyed node.
670 ///
671 /// Call after [`Self::prepare`]. This is the host-composition hook:
672 /// reserve a keyed Aetna element in the UI tree, ask for its rect
673 /// here, then record host-owned rendering into that region using the
674 /// same encoder / render flow that surrounds Aetna's pass.
675 pub fn rect_of_key(&self, key: &str) -> Option<Rect> {
676 self.core.rect_of_key(key)
677 }
678
679 /// Lay out the tree, resolve to draw ops, and upload per-frame
680 /// buffers (quad instances + glyph atlas). Must be called before
681 /// [`Self::draw`] and outside of any render pass.
682 ///
683 /// `viewport` is in **logical** pixels — the units the layout pass
684 /// works in. `scale_factor` is the HiDPI multiplier (1.0 on a
685 /// regular display, 2.0 on most modern HiDPI, can be fractional).
686 /// The host's render-pass target should be sized at physical pixels
687 /// (`viewport × scale_factor`); the runner maps logical → physical
688 /// internally so layout, fonts, and SDF math stay device-independent.
689 pub fn prepare(
690 &mut self,
691 device: &wgpu::Device,
692 queue: &wgpu::Queue,
693 root: &mut El,
694 viewport: Rect,
695 scale_factor: f32,
696 ) -> PrepareResult {
697 let mut timings = PrepareTimings::default();
698
699 // Layout + state apply + animation tick + draw_ops resolution.
700 // Writes timings.layout + timings.draw_ops. The closure feeds
701 // the runtime's continuous-redraw scan: any node bound to a
702 // shader registered with `samples_time=true` keeps the host
703 // loop ticking even when no animation is settling.
704 let time_shaders = &self.time_shaders;
705 let LayoutPrepared {
706 ops,
707 needs_redraw,
708 next_layout_redraw_in,
709 next_paint_redraw_in,
710 } = self
711 .core
712 .prepare_layout(
713 root,
714 viewport,
715 scale_factor,
716 &mut timings,
717 |handle| match handle {
718 ShaderHandle::Custom(name) => time_shaders.contains(name),
719 ShaderHandle::Stock(_) => false,
720 },
721 );
722
723 // Paint stream: pack quads, record text, preserve z-order. The
724 // closure is the wgpu-specific "is this shader registered?"
725 // query (different pipeline types per backend prevent moving the
726 // check itself into core).
727 self.text_paint.frame_begin();
728 self.icon_paint.frame_begin();
729 self.image_paint.frame_begin();
730 self.surface_paint.frame_begin();
731 let pipelines = &self.pipelines;
732 let backdrop_shaders = &self.backdrop_shaders;
733 let mut recorder = PaintRecorder {
734 text: &mut self.text_paint,
735 icons: &mut self.icon_paint,
736 images: &mut self.image_paint,
737 surfaces: &mut self.surface_paint,
738 device,
739 queue,
740 };
741 self.core.prepare_paint(
742 &ops,
743 |shader| pipelines.contains_key(shader),
744 |shader| match shader {
745 ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
746 ShaderHandle::Stock(_) => false,
747 },
748 &mut recorder,
749 scale_factor,
750 &mut timings,
751 );
752
753 // GPU upload — wgpu-specific. Resize the instance buffer if
754 // needed, then write quad_scratch + frame uniforms + flush text
755 // atlas dirty regions. Wrapped in its own scope so the
756 // `prepare::gpu_upload` span doesn't bleed into the subsequent
757 // `snapshot` call (which carries its own span).
758 {
759 aetna_core::profile_span!("prepare::gpu_upload");
760 let t_paint_end = Instant::now();
761 if self.core.quad_scratch.len() > self.instance_capacity {
762 let new_cap = self.core.quad_scratch.len().next_power_of_two();
763 self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
764 label: Some("aetna_wgpu::instance_buf (resized)"),
765 size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
766 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
767 mapped_at_creation: false,
768 });
769 self.instance_capacity = new_cap;
770 }
771 if !self.core.quad_scratch.is_empty() {
772 queue.write_buffer(
773 &self.instance_buf,
774 0,
775 bytemuck::cast_slice(&self.core.quad_scratch),
776 );
777 }
778 self.text_paint.flush(device, queue);
779 self.icon_paint.flush(device, queue);
780 self.image_paint.flush(device, queue);
781 self.surface_paint.flush(device, queue);
782 // Pin time to 0 in Settled mode so headless fixtures rendering
783 // a time-driven shader (e.g. stock::spinner) stay byte-identical
784 // run-to-run, the same way `Animation::settle()` makes the
785 // spring/tween path deterministic for SVG/PNG snapshots.
786 let time = match self.core.ui_state().animation_mode() {
787 aetna_core::AnimationMode::Settled => 0.0,
788 aetna_core::AnimationMode::Live => (Instant::now() - self.start_time).as_secs_f32(),
789 };
790 let frame = FrameUniforms {
791 viewport: [viewport.w, viewport.h],
792 time,
793 scale_factor,
794 };
795 queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
796 timings.gpu_upload = Instant::now() - t_paint_end;
797 }
798
799 // Snapshot the laid-out tree for next-frame hit-testing.
800 self.core.snapshot(root, &mut timings);
801
802 // Move resolved ops into the core's cache so a subsequent
803 // paint-only frame can reuse them without re-running layout.
804 self.core.last_ops = ops;
805
806 let next_redraw_in = match (next_layout_redraw_in, next_paint_redraw_in) {
807 (Some(a), Some(b)) => Some(a.min(b)),
808 (Some(d), None) | (None, Some(d)) => Some(d),
809 (None, None) => None,
810 };
811 PrepareResult {
812 needs_redraw,
813 next_redraw_in,
814 next_layout_redraw_in,
815 next_paint_redraw_in,
816 timings,
817 }
818 }
819
820 /// Paint-only frame: rerun [`RunnerCore::prepare_paint_cached`] +
821 /// GPU upload + frame-uniform write against the cached ops from
822 /// the most recent [`Self::prepare`] call. Skips rebuild + layout
823 /// + draw_ops + snapshot — only `frame.time` advances.
824 ///
825 /// Hosts call this when [`PrepareResult::next_paint_redraw_in`]
826 /// fires (a time-driven shader needs another frame) and no input
827 /// has been processed since the last full prepare. Input always
828 /// upgrades to the full `prepare(...)` path.
829 ///
830 /// `viewport` and `scale_factor` must match the values passed to
831 /// the most recent `prepare(...)` — a resize must go through the
832 /// full layout path. Returns the same shape of [`PrepareResult`]
833 /// for diagnostic continuity, with both deadlines re-computed
834 /// from the cached signals: `next_layout_redraw_in` is `None` (we
835 /// didn't re-evaluate), and `next_paint_redraw_in` is whatever
836 /// the cached ops still report. The host owns the layout
837 /// deadline across paint-only frames.
838 pub fn repaint(
839 &mut self,
840 device: &wgpu::Device,
841 queue: &wgpu::Queue,
842 viewport: Rect,
843 scale_factor: f32,
844 ) -> PrepareResult {
845 let mut timings = PrepareTimings::default();
846
847 self.text_paint.frame_begin();
848 self.icon_paint.frame_begin();
849 self.image_paint.frame_begin();
850 self.surface_paint.frame_begin();
851 let pipelines = &self.pipelines;
852 let backdrop_shaders = &self.backdrop_shaders;
853 let mut recorder = PaintRecorder {
854 text: &mut self.text_paint,
855 icons: &mut self.icon_paint,
856 images: &mut self.image_paint,
857 surfaces: &mut self.surface_paint,
858 device,
859 queue,
860 };
861 self.core.prepare_paint_cached(
862 |shader| pipelines.contains_key(shader),
863 |shader| match shader {
864 ShaderHandle::Custom(name) => backdrop_shaders.contains(name),
865 ShaderHandle::Stock(_) => false,
866 },
867 &mut recorder,
868 scale_factor,
869 &mut timings,
870 );
871
872 // Same GPU-upload block as prepare(); time advances even though
873 // ops are unchanged so time-driven shaders animate.
874 {
875 aetna_core::profile_span!("repaint::gpu_upload");
876 let t_paint_end = Instant::now();
877 if self.core.quad_scratch.len() > self.instance_capacity {
878 let new_cap = self.core.quad_scratch.len().next_power_of_two();
879 self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
880 label: Some("aetna_wgpu::instance_buf (resized)"),
881 size: (new_cap * std::mem::size_of::<QuadInstance>()) as u64,
882 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
883 mapped_at_creation: false,
884 });
885 self.instance_capacity = new_cap;
886 }
887 if !self.core.quad_scratch.is_empty() {
888 queue.write_buffer(
889 &self.instance_buf,
890 0,
891 bytemuck::cast_slice(&self.core.quad_scratch),
892 );
893 }
894 self.text_paint.flush(device, queue);
895 self.icon_paint.flush(device, queue);
896 self.image_paint.flush(device, queue);
897 self.surface_paint.flush(device, queue);
898 let time = match self.core.ui_state().animation_mode() {
899 AnimationMode::Settled => 0.0,
900 AnimationMode::Live => (Instant::now() - self.start_time).as_secs_f32(),
901 };
902 let frame = FrameUniforms {
903 viewport: [viewport.w, viewport.h],
904 time,
905 scale_factor,
906 };
907 queue.write_buffer(&self.frame_buf, 0, bytemuck::bytes_of(&frame));
908 timings.gpu_upload = Instant::now() - t_paint_end;
909 }
910
911 // Re-evaluate the paint lane against the cached ops so the host
912 // can re-arm the deadline. Cheap (one scan over already-resolved
913 // ops). The layout lane is left as `None`: we didn't re-run
914 // `prepare_layout`, so we have no fresh signal to report — the
915 // host's previously-set layout deadline still stands.
916 let time_shaders = &self.time_shaders;
917 let next_paint_redraw_in = self.core.scan_continuous_shaders(|handle| match handle {
918 ShaderHandle::Custom(name) => time_shaders.contains(name),
919 ShaderHandle::Stock(_) => false,
920 });
921 PrepareResult {
922 needs_redraw: next_paint_redraw_in.is_some(),
923 next_redraw_in: next_paint_redraw_in,
924 next_layout_redraw_in: None,
925 next_paint_redraw_in,
926 timings,
927 }
928 }
929
930 // ---- Input plumbing ----
931 //
932 // The host (winit-side) calls these from its event loop.
933 // Coordinates are **logical pixels** — divide winit's physical
934 // PhysicalPosition by the window scale factor before handing them in.
935
936 /// Update pointer position and recompute the hovered key.
937 /// Returns the new hovered key, if any (host can use it for cursor
938 /// styling or to decide whether to call `request_redraw`).
939 /// Pointer moved to `(x, y)` (logical px). Returns the events to
940 /// dispatch via `App::on_event` plus a `needs_redraw` flag — see
941 /// [`PointerMove`] for why hosts must gate `request_redraw` on
942 /// the flag. The hovered node is updated on `ui_state().hovered`
943 /// regardless.
944 pub fn pointer_moved(&mut self, x: f32, y: f32) -> PointerMove {
945 self.core.pointer_moved(x, y)
946 }
947
948 /// Pointer left the window — clear hover/press. Returns a
949 /// `PointerLeave` event for the previously hovered target (when
950 /// there was one); hosts should route the events through
951 /// `App::on_event` like the other pointer entry points.
952 pub fn pointer_left(&mut self) -> Vec<aetna_core::UiEvent> {
953 self.core.pointer_left()
954 }
955
956 /// File is being dragged over the window. Hosts call this from
957 /// `winit::WindowEvent::HoveredFile` (one call per file). Returns
958 /// the `FileHovered` event routed to the keyed leaf at the cursor
959 /// (or window-level if outside any keyed surface).
960 pub fn file_hovered(
961 &mut self,
962 path: std::path::PathBuf,
963 x: f32,
964 y: f32,
965 ) -> Vec<aetna_core::UiEvent> {
966 self.core.file_hovered(path, x, y)
967 }
968
969 /// File hover ended without a drop — hosts call this from
970 /// `winit::WindowEvent::HoveredFileCancelled`. Window-level event
971 /// (not routed); apps clear any drop-zone affordance.
972 pub fn file_hover_cancelled(&mut self) -> Vec<aetna_core::UiEvent> {
973 self.core.file_hover_cancelled()
974 }
975
976 /// File was dropped on the window. Hosts call this from
977 /// `winit::WindowEvent::DroppedFile` (one call per file).
978 pub fn file_dropped(
979 &mut self,
980 path: std::path::PathBuf,
981 x: f32,
982 y: f32,
983 ) -> Vec<aetna_core::UiEvent> {
984 self.core.file_dropped(path, x, y)
985 }
986
987 /// Mouse button down at `(x, y)` (logical px) for the given
988 /// `button`. For `Primary`, records the pressed key for press-
989 /// visual feedback, updates focus, and returns a `PointerDown`
990 /// event so widgets that need to react at down-time (text input
991 /// selection anchor, draggable handles) can do so. For
992 /// `Secondary` / `Middle`, records on a side channel and returns
993 /// `None`. The actual click event fires on `pointer_up`.
994 pub fn pointer_down(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
995 self.core.pointer_down(x, y, button)
996 }
997
998 /// Replace the tracked modifier mask. Hosts call this from their
999 /// platform's "modifiers changed" hook so subsequent pointer
1000 /// events (PointerDown, Drag, Click, …) stamp the current mask
1001 /// into `UiEvent.modifiers`.
1002 pub fn set_modifiers(&mut self, modifiers: KeyModifiers) {
1003 self.core.ui_state.set_modifiers(modifiers);
1004 }
1005
1006 /// Mouse button up at `(x, y)` for the given `button`. Returns
1007 /// the events the host should dispatch in order: for `Primary`,
1008 /// always a `PointerUp` (when there was a corresponding down)
1009 /// followed by an optional `Click` (when the up landed on the
1010 /// down's node). For `Secondary` / `Middle`, an optional
1011 /// `SecondaryClick` / `MiddleClick` on the same-node match.
1012 pub fn pointer_up(&mut self, x: f32, y: f32, button: PointerButton) -> Vec<UiEvent> {
1013 self.core.pointer_up(x, y, button)
1014 }
1015
1016 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1017 self.core.key_down(key, modifiers, repeat)
1018 }
1019
1020 /// Forward an OS-composed text-input string (winit's keyboard event
1021 /// `.text` field, or an `Ime::Commit`) to the focused element as a
1022 /// `TextInput` event.
1023 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1024 self.core.text_input(text)
1025 }
1026
1027 /// Replace the hotkey registry. Call once per frame, after `app.build()`,
1028 /// passing `app.hotkeys()` so chords stay in sync with state.
1029 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1030 self.core.set_hotkeys(hotkeys);
1031 }
1032
1033 /// Push the app's current selection to the runtime so the painter
1034 /// can draw highlight bands. Hosts call this once per frame
1035 /// alongside [`Self::set_hotkeys`].
1036 pub fn set_selection(&mut self, selection: aetna_core::selection::Selection) {
1037 self.core.set_selection(selection);
1038 }
1039
1040 /// Queue toast specs onto the runtime's toast stack. Hosts call
1041 /// this once per frame with `app.drain_toasts()`. Each spec is
1042 /// stamped with a monotonic id and an `expires_at` deadline
1043 /// (`now + ttl`); the next `prepare` call drops expired entries
1044 /// and synthesizes a `toast_stack` floating layer over the rest.
1045 pub fn push_toasts(&mut self, specs: Vec<aetna_core::toast::ToastSpec>) {
1046 self.core.push_toasts(specs);
1047 }
1048
1049 /// Programmatically dismiss a toast by id. Useful for cancelling
1050 /// long-TTL toasts when an external condition resolves (e.g.,
1051 /// "reconnecting…" turning into "connected").
1052 pub fn dismiss_toast(&mut self, id: u64) {
1053 self.core.dismiss_toast(id);
1054 }
1055
1056 /// Queue programmatic focus requests by widget key. Hosts call
1057 /// this once per frame with `app.drain_focus_requests()`. Each
1058 /// key is resolved during the next `prepare` against the rebuilt
1059 /// focus order; unmatched keys drop silently.
1060 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1061 self.core.push_focus_requests(keys);
1062 }
1063
1064 /// Queue programmatic scroll-to-row requests targeting virtual
1065 /// lists by key. Hosts call this once per frame with
1066 /// `app.drain_scroll_requests()`. Each request is consumed during
1067 /// the next `prepare` by the layout pass for the matching list,
1068 /// where viewport height and row heights are known. Unmatched
1069 /// list keys and out-of-range row indices drop silently.
1070 pub fn push_scroll_requests(&mut self, requests: Vec<aetna_core::scroll::ScrollRequest>) {
1071 self.core.push_scroll_requests(requests);
1072 }
1073
1074 /// Switch animation pacing. Default is [`AnimationMode::Live`].
1075 /// Headless render binaries should call this with
1076 /// [`AnimationMode::Settled`] so a single-frame snapshot reflects
1077 /// the post-animation visual without depending on integrator timing.
1078 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1079 self.core.set_animation_mode(mode);
1080 }
1081
1082 /// Apply a wheel delta in **logical** pixels at `(x, y)`. Routes to
1083 /// the deepest scrollable container under the cursor in the last
1084 /// laid-out tree. Returns `true` if the event landed on a scrollable
1085 /// (host should `request_redraw` so the next frame applies the new
1086 /// offset).
1087 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1088 self.core.pointer_wheel(x, y, dy)
1089 }
1090
1091 /// Record draws into the host-managed render pass. Call after
1092 /// [`Self::prepare`]. Paint order follows the draw-op stream.
1093 ///
1094 /// **No backdrop sampling.** This entry point cannot honor pass
1095 /// boundaries (the host owns the pass lifetime), so any
1096 /// `BackdropSnapshot` items in the paint stream are no-ops and any
1097 /// shader bound with `samples_backdrop=true` reads an undefined
1098 /// backdrop binding. Use [`Self::render`] for backdrop-aware
1099 /// rendering.
1100 pub fn draw<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>) {
1101 self.draw_items(pass, &self.core.paint_items);
1102 }
1103
1104 /// Record draws into a host-supplied encoder, owning pass
1105 /// lifetimes ourselves so backdrop-sampling shaders can sample a
1106 /// snapshot of Pass A's content.
1107 ///
1108 /// The host hands us:
1109 /// - the encoder (we record into it),
1110 /// - the color target's `wgpu::Texture` (used as `copy_src` when
1111 /// we snapshot it; must include `COPY_SRC` in its usage flags),
1112 /// - the corresponding `wgpu::TextureView` (we attach it to every
1113 /// render pass we begin), and
1114 /// - the `LoadOp` to use on the *first* pass — `Clear(color)` to
1115 /// clear behind us, `Load` to composite onto whatever was
1116 /// already in the target.
1117 ///
1118 /// Multi-pass schedule when the paint stream contains a
1119 /// `BackdropSnapshot`:
1120 ///
1121 /// 1. Pass A — every paint item before the snapshot, with the
1122 /// caller-supplied `LoadOp`.
1123 /// 2. `copy_texture_to_texture` — target → snapshot.
1124 /// 3. Pass B — paint items from the snapshot onward, with
1125 /// `LoadOp::Load` so Pass A's pixels remain underneath.
1126 ///
1127 /// Without a snapshot, this collapses to a single pass and is
1128 /// equivalent to [`Self::draw`] called inside a host-managed
1129 /// pass with the same `LoadOp`.
1130 pub fn render(
1131 &mut self,
1132 device: &wgpu::Device,
1133 encoder: &mut wgpu::CommandEncoder,
1134 target_tex: &wgpu::Texture,
1135 target_view: &wgpu::TextureView,
1136 msaa_view: Option<&wgpu::TextureView>,
1137 load_op: wgpu::LoadOp<wgpu::Color>,
1138 ) {
1139 // When MSAA is in use, the actual color attachment is the
1140 // multisampled view and `target_view` becomes its resolve
1141 // target. `target_tex` is always the resolved (single-sample)
1142 // texture, so the snapshot copy below works whether MSAA is on
1143 // or not — the resolve happens at end-of-Pass-A.
1144 let attachment_view = msaa_view.unwrap_or(target_view);
1145 let resolve_target = msaa_view.map(|_| target_view);
1146
1147 // Locate the (at most one) snapshot boundary.
1148 let split_at = self
1149 .core
1150 .paint_items
1151 .iter()
1152 .position(|p| matches!(p, PaintItem::BackdropSnapshot));
1153
1154 if let Some(idx) = split_at {
1155 self.ensure_snapshot(device, target_tex);
1156 // Pass A
1157 {
1158 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1159 label: Some("aetna_wgpu::pass_a"),
1160 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1161 view: attachment_view,
1162 resolve_target,
1163 depth_slice: None,
1164 ops: wgpu::Operations {
1165 load: load_op,
1166 store: wgpu::StoreOp::Store,
1167 },
1168 })],
1169 depth_stencil_attachment: None,
1170 timestamp_writes: None,
1171 occlusion_query_set: None,
1172 multiview_mask: None,
1173 });
1174 self.draw_items(&mut pass, &self.core.paint_items[..idx]);
1175 }
1176 // Snapshot copy. Target must support COPY_SRC; snapshot
1177 // texture (created in `ensure_snapshot`) supports COPY_DST
1178 // + TEXTURE_BINDING.
1179 let snapshot = self.snapshot.as_ref().expect("snapshot ensured");
1180 encoder.copy_texture_to_texture(
1181 wgpu::TexelCopyTextureInfo {
1182 texture: target_tex,
1183 mip_level: 0,
1184 origin: wgpu::Origin3d::ZERO,
1185 aspect: wgpu::TextureAspect::All,
1186 },
1187 wgpu::TexelCopyTextureInfo {
1188 texture: &snapshot.texture,
1189 mip_level: 0,
1190 origin: wgpu::Origin3d::ZERO,
1191 aspect: wgpu::TextureAspect::All,
1192 },
1193 wgpu::Extent3d {
1194 width: snapshot.extent.0,
1195 height: snapshot.extent.1,
1196 depth_or_array_layers: 1,
1197 },
1198 );
1199 // Pass B
1200 {
1201 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1202 label: Some("aetna_wgpu::pass_b"),
1203 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1204 view: attachment_view,
1205 resolve_target,
1206 depth_slice: None,
1207 ops: wgpu::Operations {
1208 load: wgpu::LoadOp::Load,
1209 store: wgpu::StoreOp::Store,
1210 },
1211 })],
1212 depth_stencil_attachment: None,
1213 timestamp_writes: None,
1214 occlusion_query_set: None,
1215 multiview_mask: None,
1216 });
1217 // Skip the snapshot item itself; it's a marker, not a draw.
1218 self.draw_items(&mut pass, &self.core.paint_items[idx + 1..]);
1219 }
1220 } else {
1221 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1222 label: Some("aetna_wgpu::pass"),
1223 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1224 view: attachment_view,
1225 resolve_target,
1226 depth_slice: None,
1227 ops: wgpu::Operations {
1228 load: load_op,
1229 store: wgpu::StoreOp::Store,
1230 },
1231 })],
1232 depth_stencil_attachment: None,
1233 timestamp_writes: None,
1234 occlusion_query_set: None,
1235 multiview_mask: None,
1236 });
1237 self.draw_items(&mut pass, &self.core.paint_items);
1238 }
1239 }
1240
1241 /// (Re)allocate the snapshot texture to match `target_tex`'s
1242 /// extent + format. Idempotent when the size matches; rebuilds the
1243 /// `backdrop_bind_group` whenever the snapshot is recreated.
1244 fn ensure_snapshot(&mut self, device: &wgpu::Device, target_tex: &wgpu::Texture) {
1245 let extent = target_tex.size();
1246 let want = (extent.width, extent.height);
1247 if let Some(s) = &self.snapshot
1248 && s.extent == want
1249 {
1250 return;
1251 }
1252 let texture = device.create_texture(&wgpu::TextureDescriptor {
1253 label: Some("aetna_wgpu::backdrop_snapshot"),
1254 size: wgpu::Extent3d {
1255 width: want.0,
1256 height: want.1,
1257 depth_or_array_layers: 1,
1258 },
1259 mip_level_count: 1,
1260 sample_count: 1,
1261 dimension: wgpu::TextureDimension::D2,
1262 format: self.target_format,
1263 usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
1264 view_formats: &[],
1265 });
1266 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1267 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1268 label: Some("aetna_wgpu::backdrop_bind_group"),
1269 layout: &self.backdrop_bind_layout,
1270 entries: &[
1271 wgpu::BindGroupEntry {
1272 binding: 0,
1273 resource: wgpu::BindingResource::TextureView(&view),
1274 },
1275 wgpu::BindGroupEntry {
1276 binding: 1,
1277 resource: wgpu::BindingResource::Sampler(&self.backdrop_sampler),
1278 },
1279 ],
1280 });
1281 self.snapshot = Some(SnapshotTexture {
1282 texture,
1283 extent: want,
1284 });
1285 self.backdrop_bind_group = Some(bind_group);
1286 }
1287
1288 /// Walk a slice of `PaintItem`s into the given pass. Helper shared
1289 /// by [`Self::draw`] and [`Self::render`]. `BackdropSnapshot`
1290 /// items are no-ops here; `render()` handles them by splitting
1291 /// the slice before passing to this helper.
1292 fn draw_items<'pass>(
1293 &'pass self,
1294 pass: &mut wgpu::RenderPass<'pass>,
1295 items: &'pass [PaintItem],
1296 ) {
1297 let full = PhysicalScissor {
1298 x: 0,
1299 y: 0,
1300 w: self.core.viewport_px.0,
1301 h: self.core.viewport_px.1,
1302 };
1303 for item in items {
1304 match *item {
1305 PaintItem::QuadRun(index) => {
1306 let run = &self.core.runs[index];
1307 set_scissor(pass, run.scissor, full);
1308 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1309 let is_backdrop_shader = matches!(
1310 run.handle,
1311 ShaderHandle::Custom(name) if self.backdrop_shaders.contains(name)
1312 );
1313 if is_backdrop_shader && let Some(bg) = &self.backdrop_bind_group {
1314 pass.set_bind_group(1, bg, &[]);
1315 }
1316 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1317 pass.set_vertex_buffer(1, self.instance_buf.slice(..));
1318 let pipeline = self
1319 .pipelines
1320 .get(&run.handle)
1321 .expect("run handle has no pipeline (bug in prepare)");
1322 pass.set_pipeline(pipeline);
1323 pass.draw(0..4, run.first..run.first + run.count);
1324 }
1325 PaintItem::Text(index) => {
1326 let run = self.text_paint.run(index);
1327 set_scissor(pass, run.scissor, full);
1328 pass.set_pipeline(self.text_paint.pipeline_for(run.kind));
1329 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1330 // Highlight runs use a frame-uniform-only pipeline.
1331 // Glyph kinds bind the active atlas page at group 1.
1332 if !matches!(run.kind, crate::text::TextRunKind::Highlight) {
1333 pass.set_bind_group(
1334 1,
1335 self.text_paint.page_bind_group(run.kind, run.page),
1336 &[],
1337 );
1338 }
1339 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1340 pass.set_vertex_buffer(1, self.text_paint.instance_buf_for(run.kind).slice(..));
1341 pass.draw(0..4, run.first..run.first + run.count);
1342 }
1343 PaintItem::IconRun(index) | PaintItem::Vector(index) => {
1344 // `PaintItem::Vector` is structurally identical to
1345 // `PaintItem::IconRun` — both index into the same
1346 // `IconPaint::runs` Vec since `record_vector`
1347 // appends there too. The variant is kept distinct
1348 // for paint-stream provenance (icon vs app vector)
1349 // but the dispatch is the same.
1350 let run = self.icon_paint.run(index);
1351 set_scissor(pass, run.scissor, full);
1352 match run.kind {
1353 IconRunKind::Tess => {
1354 pass.set_pipeline(self.icon_paint.tess_pipeline(run.material));
1355 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1356 pass.set_vertex_buffer(0, self.icon_paint.tess_vertex_buf().slice(..));
1357 pass.draw(run.first..run.first + run.count, 0..1);
1358 }
1359 IconRunKind::Msdf => {
1360 pass.set_pipeline(self.icon_paint.msdf_pipeline());
1361 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1362 pass.set_bind_group(
1363 1,
1364 self.icon_paint.msdf_page_bind_group(run.page),
1365 &[],
1366 );
1367 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1368 pass.set_vertex_buffer(
1369 1,
1370 self.icon_paint.msdf_instance_buf().slice(..),
1371 );
1372 pass.draw(0..4, run.first..run.first + run.count);
1373 }
1374 }
1375 }
1376 PaintItem::Image(index) => {
1377 let run = self.image_paint.run(index);
1378 set_scissor(pass, run.scissor, full);
1379 pass.set_pipeline(self.image_paint.pipeline());
1380 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1381 pass.set_bind_group(1, self.image_paint.bind_group_for_run(run), &[]);
1382 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1383 pass.set_vertex_buffer(1, self.image_paint.instance_buf().slice(..));
1384 pass.draw(0..4, run.first..run.first + run.count);
1385 }
1386 PaintItem::AppTexture(index) => {
1387 let run = self.surface_paint.run(index);
1388 set_scissor(pass, run.scissor, full);
1389 pass.set_pipeline(self.surface_paint.pipeline_for(run.alpha));
1390 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1391 pass.set_bind_group(1, self.surface_paint.bind_group_for_run(run), &[]);
1392 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1393 pass.set_vertex_buffer(1, self.surface_paint.instance_buf().slice(..));
1394 pass.draw(0..4, run.first..run.first + run.count);
1395 }
1396 PaintItem::BackdropSnapshot => {
1397 // Marker only — `render()` splits the slice on
1398 // these and never includes one in a draw range.
1399 }
1400 }
1401 }
1402 }
1403}