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, Pointer, 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::icons::svg::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 `p.x, p.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. Mouse-only hosts can construct `p` via
944 /// [`Pointer::moving`].
945 pub fn pointer_moved(&mut self, p: Pointer) -> PointerMove {
946 self.core.pointer_moved(p)
947 }
948
949 /// Pointer left the window — clear hover/press. Returns a
950 /// `PointerLeave` event for the previously hovered target (when
951 /// there was one); hosts should route the events through
952 /// `App::on_event` like the other pointer entry points.
953 pub fn pointer_left(&mut self) -> Vec<aetna_core::UiEvent> {
954 self.core.pointer_left()
955 }
956
957 /// File is being dragged over the window. Hosts call this from
958 /// `winit::WindowEvent::HoveredFile` (one call per file). Returns
959 /// the `FileHovered` event routed to the keyed leaf at the cursor
960 /// (or window-level if outside any keyed surface).
961 pub fn file_hovered(
962 &mut self,
963 path: std::path::PathBuf,
964 x: f32,
965 y: f32,
966 ) -> Vec<aetna_core::UiEvent> {
967 self.core.file_hovered(path, x, y)
968 }
969
970 /// File hover ended without a drop — hosts call this from
971 /// `winit::WindowEvent::HoveredFileCancelled`. Window-level event
972 /// (not routed); apps clear any drop-zone affordance.
973 pub fn file_hover_cancelled(&mut self) -> Vec<aetna_core::UiEvent> {
974 self.core.file_hover_cancelled()
975 }
976
977 /// File was dropped on the window. Hosts call this from
978 /// `winit::WindowEvent::DroppedFile` (one call per file).
979 pub fn file_dropped(
980 &mut self,
981 path: std::path::PathBuf,
982 x: f32,
983 y: f32,
984 ) -> Vec<aetna_core::UiEvent> {
985 self.core.file_dropped(path, x, y)
986 }
987
988 /// Whether a primary press at `(x, y)` (logical px) would land
989 /// on a node that opted into `capture_keys` — the marker the
990 /// library uses for text-input-style widgets. Hosts query this
991 /// from a DOM pointerdown handler to decide whether to focus
992 /// a hidden textarea (so the soft keyboard can open in the
993 /// user-gesture context). See
994 /// [`RunnerCore::would_press_focus_text_input`] for details.
995 pub fn would_press_focus_text_input(&self, x: f32, y: f32) -> bool {
996 self.core.would_press_focus_text_input(x, y)
997 }
998
999 /// Whether the currently focused node is a text-input-style
1000 /// widget (i.e. has `capture_keys` set). Hosts mirror this each
1001 /// frame into platform affordances such as the on-screen
1002 /// keyboard or IME compose-window placement.
1003 pub fn focused_captures_keys(&self) -> bool {
1004 self.core.focused_captures_keys()
1005 }
1006
1007 /// Pointer pressed at `p.x, p.y` (logical px) for `p.button`. For
1008 /// `Primary`, records the pressed key for press-visual feedback,
1009 /// updates focus, and returns a `PointerDown` event so widgets that
1010 /// need to react at down-time (text input selection anchor,
1011 /// draggable handles) can do so. For `Secondary` / `Middle`, records
1012 /// on a side channel and returns `None`. The actual click event
1013 /// fires on `pointer_up`. Mouse-only hosts can construct `p` via
1014 /// [`Pointer::mouse`].
1015 pub fn pointer_down(&mut self, p: Pointer) -> Vec<UiEvent> {
1016 self.core.pointer_down(p)
1017 }
1018
1019 /// Replace the tracked modifier mask. Hosts call this from their
1020 /// platform's "modifiers changed" hook so subsequent pointer
1021 /// events (PointerDown, Drag, Click, …) stamp the current mask
1022 /// into `UiEvent.modifiers`.
1023 pub fn set_modifiers(&mut self, modifiers: KeyModifiers) {
1024 self.core.ui_state.set_modifiers(modifiers);
1025 }
1026
1027 /// Pointer released at `p.x, p.y` for `p.button`. Returns the
1028 /// events the host should dispatch in order: for `Primary`, always
1029 /// a `PointerUp` (when there was a corresponding down) followed
1030 /// by an optional `Click` (when the up landed on the down's
1031 /// node). For `Secondary` / `Middle`, an optional `SecondaryClick`
1032 /// / `MiddleClick` on the same-node match. Mouse-only hosts can
1033 /// construct `p` via [`Pointer::mouse`].
1034 pub fn pointer_up(&mut self, p: Pointer) -> Vec<UiEvent> {
1035 self.core.pointer_up(p)
1036 }
1037
1038 pub fn key_down(&mut self, key: UiKey, modifiers: KeyModifiers, repeat: bool) -> Vec<UiEvent> {
1039 self.core.key_down(key, modifiers, repeat)
1040 }
1041
1042 /// Forward an OS-composed text-input string (winit's keyboard event
1043 /// `.text` field, or an `Ime::Commit`) to the focused element as a
1044 /// `TextInput` event.
1045 pub fn text_input(&mut self, text: String) -> Option<UiEvent> {
1046 self.core.text_input(text)
1047 }
1048
1049 /// Replace the hotkey registry. Call once per frame, after `app.build()`,
1050 /// passing `app.hotkeys()` so chords stay in sync with state.
1051 pub fn set_hotkeys(&mut self, hotkeys: Vec<(KeyChord, String)>) {
1052 self.core.set_hotkeys(hotkeys);
1053 }
1054
1055 /// Push the app's current selection to the runtime so the painter
1056 /// can draw highlight bands. Hosts call this once per frame
1057 /// alongside [`Self::set_hotkeys`].
1058 pub fn set_selection(&mut self, selection: aetna_core::selection::Selection) {
1059 self.core.set_selection(selection);
1060 }
1061
1062 /// Resolve the runtime's current selection to a text payload from
1063 /// the most recently laid-out tree. See
1064 /// [`RunnerCore::selected_text`] — virtual-list rows are realized
1065 /// during layout, so a freshly built app tree would miss them and
1066 /// a `Ctrl+C` lookup that walked it would silently come back empty.
1067 pub fn selected_text(&self) -> Option<String> {
1068 self.core.selected_text()
1069 }
1070
1071 /// Resolve an explicit [`aetna_core::selection::Selection`] against
1072 /// the last laid-out tree. See [`RunnerCore::selected_text_for`].
1073 pub fn selected_text_for(
1074 &self,
1075 selection: &aetna_core::selection::Selection,
1076 ) -> Option<String> {
1077 self.core.selected_text_for(selection)
1078 }
1079
1080 /// Queue toast specs onto the runtime's toast stack. Hosts call
1081 /// this once per frame with `app.drain_toasts()`. Each spec is
1082 /// stamped with a monotonic id and an `expires_at` deadline
1083 /// (`now + ttl`); the next `prepare` call drops expired entries
1084 /// and synthesizes a `toast_stack` floating layer over the rest.
1085 pub fn push_toasts(&mut self, specs: Vec<aetna_core::toast::ToastSpec>) {
1086 self.core.push_toasts(specs);
1087 }
1088
1089 /// Programmatically dismiss a toast by id. Useful for cancelling
1090 /// long-TTL toasts when an external condition resolves (e.g.,
1091 /// "reconnecting…" turning into "connected").
1092 pub fn dismiss_toast(&mut self, id: u64) {
1093 self.core.dismiss_toast(id);
1094 }
1095
1096 /// Queue programmatic focus requests by widget key. Hosts call
1097 /// this once per frame with `app.drain_focus_requests()`. Each
1098 /// key is resolved during the next `prepare` against the rebuilt
1099 /// focus order; unmatched keys drop silently.
1100 pub fn push_focus_requests(&mut self, keys: Vec<String>) {
1101 self.core.push_focus_requests(keys);
1102 }
1103
1104 /// Queue programmatic scroll-to-row requests targeting virtual
1105 /// lists by key. Hosts call this once per frame with
1106 /// `app.drain_scroll_requests()`. Each request is consumed during
1107 /// the next `prepare` by the layout pass for the matching list,
1108 /// where viewport height and row heights are known. Unmatched
1109 /// list keys and out-of-range row indices drop silently.
1110 pub fn push_scroll_requests(&mut self, requests: Vec<aetna_core::scroll::ScrollRequest>) {
1111 self.core.push_scroll_requests(requests);
1112 }
1113
1114 /// Switch animation pacing. Default is [`AnimationMode::Live`].
1115 /// Headless render binaries should call this with
1116 /// [`AnimationMode::Settled`] so a single-frame snapshot reflects
1117 /// the post-animation visual without depending on integrator timing.
1118 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
1119 self.core.set_animation_mode(mode);
1120 }
1121
1122 /// Apply a wheel delta in **logical** pixels at `(x, y)`. Routes to
1123 /// the deepest scrollable container under the cursor in the last
1124 /// laid-out tree. Returns `true` if the event landed on a scrollable
1125 /// (host should `request_redraw` so the next frame applies the new
1126 /// offset).
1127 pub fn pointer_wheel(&mut self, x: f32, y: f32, dy: f32) -> bool {
1128 self.core.pointer_wheel(x, y, dy)
1129 }
1130
1131 /// Drain time-driven input events whose deadline has passed (touch
1132 /// long-press today; later: hold-to-repeat, etc.). Hosts call this
1133 /// once per frame before dispatching pointer events. `now` is
1134 /// `web_time::Instant` rather than `std::time::Instant` so the
1135 /// signature compiles on wasm32 — `web_time` aliases to std on
1136 /// native, so existing native callers passing `Instant::now()`
1137 /// from std still work. See [`aetna_core::RunnerCore::poll_input`].
1138 pub fn poll_input(&mut self, now: web_time::Instant) -> Vec<aetna_core::UiEvent> {
1139 self.core.poll_input(now)
1140 }
1141
1142 /// Record draws into the host-managed render pass. Call after
1143 /// [`Self::prepare`]. Paint order follows the draw-op stream.
1144 ///
1145 /// **No backdrop sampling.** This entry point cannot honor pass
1146 /// boundaries (the host owns the pass lifetime), so any
1147 /// `BackdropSnapshot` items in the paint stream are no-ops and any
1148 /// shader bound with `samples_backdrop=true` reads an undefined
1149 /// backdrop binding. Use [`Self::render`] for backdrop-aware
1150 /// rendering.
1151 pub fn draw<'pass>(&'pass self, pass: &mut wgpu::RenderPass<'pass>) {
1152 self.draw_items(pass, &self.core.paint_items);
1153 }
1154
1155 /// Record draws into a host-supplied encoder, owning pass
1156 /// lifetimes ourselves so backdrop-sampling shaders can sample a
1157 /// snapshot of Pass A's content.
1158 ///
1159 /// The host hands us:
1160 /// - the encoder (we record into it),
1161 /// - the color target's `wgpu::Texture` (used as `copy_src` when
1162 /// we snapshot it; must include `COPY_SRC` in its usage flags),
1163 /// - the corresponding `wgpu::TextureView` (we attach it to every
1164 /// render pass we begin), and
1165 /// - the `LoadOp` to use on the *first* pass — `Clear(color)` to
1166 /// clear behind us, `Load` to composite onto whatever was
1167 /// already in the target.
1168 ///
1169 /// Multi-pass schedule when the paint stream contains a
1170 /// `BackdropSnapshot`:
1171 ///
1172 /// 1. Pass A — every paint item before the snapshot, with the
1173 /// caller-supplied `LoadOp`.
1174 /// 2. `copy_texture_to_texture` — target → snapshot.
1175 /// 3. Pass B — paint items from the snapshot onward, with
1176 /// `LoadOp::Load` so Pass A's pixels remain underneath.
1177 ///
1178 /// Without a snapshot, this collapses to a single pass and is
1179 /// equivalent to [`Self::draw`] called inside a host-managed
1180 /// pass with the same `LoadOp`.
1181 pub fn render(
1182 &mut self,
1183 device: &wgpu::Device,
1184 encoder: &mut wgpu::CommandEncoder,
1185 target_tex: &wgpu::Texture,
1186 target_view: &wgpu::TextureView,
1187 msaa_view: Option<&wgpu::TextureView>,
1188 load_op: wgpu::LoadOp<wgpu::Color>,
1189 ) {
1190 // When MSAA is in use, the actual color attachment is the
1191 // multisampled view and `target_view` becomes its resolve
1192 // target. `target_tex` is always the resolved (single-sample)
1193 // texture, so the snapshot copy below works whether MSAA is on
1194 // or not — the resolve happens at end-of-Pass-A.
1195 let attachment_view = msaa_view.unwrap_or(target_view);
1196 let resolve_target = msaa_view.map(|_| target_view);
1197
1198 // Locate the (at most one) snapshot boundary.
1199 let split_at = self
1200 .core
1201 .paint_items
1202 .iter()
1203 .position(|p| matches!(p, PaintItem::BackdropSnapshot));
1204
1205 if let Some(idx) = split_at {
1206 self.ensure_snapshot(device, target_tex);
1207 // Pass A
1208 {
1209 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1210 label: Some("aetna_wgpu::pass_a"),
1211 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1212 view: attachment_view,
1213 resolve_target,
1214 depth_slice: None,
1215 ops: wgpu::Operations {
1216 load: load_op,
1217 store: wgpu::StoreOp::Store,
1218 },
1219 })],
1220 depth_stencil_attachment: None,
1221 timestamp_writes: None,
1222 occlusion_query_set: None,
1223 multiview_mask: None,
1224 });
1225 self.draw_items(&mut pass, &self.core.paint_items[..idx]);
1226 }
1227 // Snapshot copy. Target must support COPY_SRC; snapshot
1228 // texture (created in `ensure_snapshot`) supports COPY_DST
1229 // + TEXTURE_BINDING.
1230 let snapshot = self.snapshot.as_ref().expect("snapshot ensured");
1231 encoder.copy_texture_to_texture(
1232 wgpu::TexelCopyTextureInfo {
1233 texture: target_tex,
1234 mip_level: 0,
1235 origin: wgpu::Origin3d::ZERO,
1236 aspect: wgpu::TextureAspect::All,
1237 },
1238 wgpu::TexelCopyTextureInfo {
1239 texture: &snapshot.texture,
1240 mip_level: 0,
1241 origin: wgpu::Origin3d::ZERO,
1242 aspect: wgpu::TextureAspect::All,
1243 },
1244 wgpu::Extent3d {
1245 width: snapshot.extent.0,
1246 height: snapshot.extent.1,
1247 depth_or_array_layers: 1,
1248 },
1249 );
1250 // Pass B
1251 {
1252 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1253 label: Some("aetna_wgpu::pass_b"),
1254 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1255 view: attachment_view,
1256 resolve_target,
1257 depth_slice: None,
1258 ops: wgpu::Operations {
1259 load: wgpu::LoadOp::Load,
1260 store: wgpu::StoreOp::Store,
1261 },
1262 })],
1263 depth_stencil_attachment: None,
1264 timestamp_writes: None,
1265 occlusion_query_set: None,
1266 multiview_mask: None,
1267 });
1268 // Skip the snapshot item itself; it's a marker, not a draw.
1269 self.draw_items(&mut pass, &self.core.paint_items[idx + 1..]);
1270 }
1271 } else {
1272 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1273 label: Some("aetna_wgpu::pass"),
1274 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1275 view: attachment_view,
1276 resolve_target,
1277 depth_slice: None,
1278 ops: wgpu::Operations {
1279 load: load_op,
1280 store: wgpu::StoreOp::Store,
1281 },
1282 })],
1283 depth_stencil_attachment: None,
1284 timestamp_writes: None,
1285 occlusion_query_set: None,
1286 multiview_mask: None,
1287 });
1288 self.draw_items(&mut pass, &self.core.paint_items);
1289 }
1290 }
1291
1292 /// (Re)allocate the snapshot texture to match `target_tex`'s
1293 /// extent + format. Idempotent when the size matches; rebuilds the
1294 /// `backdrop_bind_group` whenever the snapshot is recreated.
1295 fn ensure_snapshot(&mut self, device: &wgpu::Device, target_tex: &wgpu::Texture) {
1296 let extent = target_tex.size();
1297 let want = (extent.width, extent.height);
1298 if let Some(s) = &self.snapshot
1299 && s.extent == want
1300 {
1301 return;
1302 }
1303 let texture = device.create_texture(&wgpu::TextureDescriptor {
1304 label: Some("aetna_wgpu::backdrop_snapshot"),
1305 size: wgpu::Extent3d {
1306 width: want.0,
1307 height: want.1,
1308 depth_or_array_layers: 1,
1309 },
1310 mip_level_count: 1,
1311 sample_count: 1,
1312 dimension: wgpu::TextureDimension::D2,
1313 format: self.target_format,
1314 usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
1315 view_formats: &[],
1316 });
1317 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1318 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1319 label: Some("aetna_wgpu::backdrop_bind_group"),
1320 layout: &self.backdrop_bind_layout,
1321 entries: &[
1322 wgpu::BindGroupEntry {
1323 binding: 0,
1324 resource: wgpu::BindingResource::TextureView(&view),
1325 },
1326 wgpu::BindGroupEntry {
1327 binding: 1,
1328 resource: wgpu::BindingResource::Sampler(&self.backdrop_sampler),
1329 },
1330 ],
1331 });
1332 self.snapshot = Some(SnapshotTexture {
1333 texture,
1334 extent: want,
1335 });
1336 self.backdrop_bind_group = Some(bind_group);
1337 }
1338
1339 /// Walk a slice of `PaintItem`s into the given pass. Helper shared
1340 /// by [`Self::draw`] and [`Self::render`]. `BackdropSnapshot`
1341 /// items are no-ops here; `render()` handles them by splitting
1342 /// the slice before passing to this helper.
1343 fn draw_items<'pass>(
1344 &'pass self,
1345 pass: &mut wgpu::RenderPass<'pass>,
1346 items: &'pass [PaintItem],
1347 ) {
1348 let full = PhysicalScissor {
1349 x: 0,
1350 y: 0,
1351 w: self.core.viewport_px.0,
1352 h: self.core.viewport_px.1,
1353 };
1354 for item in items {
1355 match *item {
1356 PaintItem::QuadRun(index) => {
1357 let run = &self.core.runs[index];
1358 set_scissor(pass, run.scissor, full);
1359 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1360 let is_backdrop_shader = matches!(
1361 run.handle,
1362 ShaderHandle::Custom(name) if self.backdrop_shaders.contains(name)
1363 );
1364 if is_backdrop_shader && let Some(bg) = &self.backdrop_bind_group {
1365 pass.set_bind_group(1, bg, &[]);
1366 }
1367 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1368 pass.set_vertex_buffer(1, self.instance_buf.slice(..));
1369 let pipeline = self
1370 .pipelines
1371 .get(&run.handle)
1372 .expect("run handle has no pipeline (bug in prepare)");
1373 pass.set_pipeline(pipeline);
1374 pass.draw(0..4, run.first..run.first + run.count);
1375 }
1376 PaintItem::Text(index) => {
1377 let run = self.text_paint.run(index);
1378 set_scissor(pass, run.scissor, full);
1379 pass.set_pipeline(self.text_paint.pipeline_for(run.kind));
1380 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1381 // Highlight runs use a frame-uniform-only pipeline.
1382 // Glyph kinds bind the active atlas page at group 1.
1383 if !matches!(run.kind, crate::text::TextRunKind::Highlight) {
1384 pass.set_bind_group(
1385 1,
1386 self.text_paint.page_bind_group(run.kind, run.page),
1387 &[],
1388 );
1389 }
1390 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1391 pass.set_vertex_buffer(1, self.text_paint.instance_buf_for(run.kind).slice(..));
1392 pass.draw(0..4, run.first..run.first + run.count);
1393 }
1394 PaintItem::IconRun(index) | PaintItem::Vector(index) => {
1395 // `PaintItem::Vector` is structurally identical to
1396 // `PaintItem::IconRun` — both index into the same
1397 // `IconPaint::runs` Vec since `record_vector`
1398 // appends there too. The variant is kept distinct
1399 // for paint-stream provenance (icon vs app vector)
1400 // but the dispatch is the same.
1401 let run = self.icon_paint.run(index);
1402 set_scissor(pass, run.scissor, full);
1403 match run.kind {
1404 IconRunKind::Tess => {
1405 pass.set_pipeline(self.icon_paint.tess_pipeline(run.material));
1406 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1407 pass.set_vertex_buffer(0, self.icon_paint.tess_vertex_buf().slice(..));
1408 pass.draw(run.first..run.first + run.count, 0..1);
1409 }
1410 IconRunKind::Msdf => {
1411 pass.set_pipeline(self.icon_paint.msdf_pipeline());
1412 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1413 pass.set_bind_group(
1414 1,
1415 self.icon_paint.msdf_page_bind_group(run.page),
1416 &[],
1417 );
1418 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1419 pass.set_vertex_buffer(
1420 1,
1421 self.icon_paint.msdf_instance_buf().slice(..),
1422 );
1423 pass.draw(0..4, run.first..run.first + run.count);
1424 }
1425 }
1426 }
1427 PaintItem::Image(index) => {
1428 let run = self.image_paint.run(index);
1429 set_scissor(pass, run.scissor, full);
1430 pass.set_pipeline(self.image_paint.pipeline());
1431 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1432 pass.set_bind_group(1, self.image_paint.bind_group_for_run(run), &[]);
1433 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1434 pass.set_vertex_buffer(1, self.image_paint.instance_buf().slice(..));
1435 pass.draw(0..4, run.first..run.first + run.count);
1436 }
1437 PaintItem::AppTexture(index) => {
1438 let run = self.surface_paint.run(index);
1439 set_scissor(pass, run.scissor, full);
1440 pass.set_pipeline(self.surface_paint.pipeline_for(run.alpha));
1441 pass.set_bind_group(0, &self.quad_bind_group, &[]);
1442 pass.set_bind_group(1, self.surface_paint.bind_group_for_run(run), &[]);
1443 pass.set_vertex_buffer(0, self.quad_vbo.slice(..));
1444 pass.set_vertex_buffer(1, self.surface_paint.instance_buf().slice(..));
1445 pass.draw(0..4, run.first..run.first + run.count);
1446 }
1447 PaintItem::BackdropSnapshot => {
1448 // Marker only — `render()` splits the slice on
1449 // these and never includes one in a draw range.
1450 }
1451 }
1452 }
1453 }
1454}