Skip to main content

wisp/
render.rs

1//! Renderer — pipeline cache, draw-call batcher, filter pass orchestrator.
2//!
3//! Evolution:
4//!   M0.5: hardcoded triangle.
5//!   M0.6: textured-quad path (`render_quad`).
6//!   M0.9: sprite batcher with scene-graph traversal (`render_stage`).
7//!   M0.16: filter pass orchestrator.
8
9pub mod batcher;
10pub mod pass;
11pub mod pipeline;
12
13mod advanced_blend;
14mod blend_pipeline;
15mod blit;
16mod clip;
17mod flex_text_pipeline;
18mod graphics_pipeline;
19mod mask_cache;
20pub mod mask_combine;
21mod mask_compose;
22mod mask_texture;
23mod mesh_pipeline;
24mod path_clip;
25mod path_mask_texture;
26mod quad_pipeline;
27mod scene_walk;
28mod sprite_pipeline;
29mod text_pipeline;
30mod triangle_pipeline;
31
32use flex_text_pipeline::FlexTextPipeline;
33use graphics_pipeline::GraphicsPipeline;
34use mesh_pipeline::MeshPipeline;
35use quad_pipeline::QuadPipeline;
36use sprite_pipeline::SpritePipeline;
37use text_pipeline::TextPipeline;
38use triangle_pipeline::TrianglePipeline;
39
40use crate::application::Application;
41use crate::color::Color;
42use crate::error::Error;
43use crate::filter::{Filter, FilterContext};
44use crate::scene::Stage;
45use crate::scene::clip::MaskShape;
46use crate::texture::Texture;
47use crate::texture::render_texture::RenderTexture;
48
49/// Frame statistics returned by [`Renderer::render_stage`].
50#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
51pub struct RenderStats {
52    /// Number of `draw` calls submitted to the GPU.
53    pub draw_calls: u32,
54    /// Total sprites rendered across all batches.
55    pub sprites_drawn: u32,
56    /// Total graphics primitives rendered.
57    pub graphics_drawn: u32,
58    /// Total text glyphs rendered.
59    pub glyphs_drawn: u32,
60    /// Total meshes rendered.
61    pub meshes_drawn: u32,
62    /// Total [`crate::scene::FlexText`] nodes rendered in the late
63    /// pass (rendered after [`Graphics`](crate::scene::Graphics)).
64    pub flex_text_drawn: u32,
65}
66
67/// 2D renderer.
68///
69/// Owns the GPU pipelines used to draw scenes onto a [`wgpu::TextureView`].
70/// Construct one per output format (surface or `RenderTexture`).
71pub struct Renderer {
72    triangle: TrianglePipeline,
73    quad: QuadPipeline,
74    sprite: SpritePipeline,
75    graphics: GraphicsPipeline,
76    text: TextPipeline,
77    flex_text: FlexTextPipeline,
78    mesh: MeshPipeline,
79    advanced_blend: advanced_blend::AdvancedBlendPipelines,
80    blit: blit::BlitPipeline,
81    clip: clip::ClipPipeline,
82    path_clip: path_clip::PathClipPipeline,
83    mask_texture: mask_texture::MaskTexturePipeline,
84    path_mask_texture: path_mask_texture::PathMaskTexturePipeline,
85    mask_compose: mask_compose::MaskComposePipeline,
86    mask_combine: mask_combine::MaskCombinePipeline,
87    mask_cache: mask_cache::MaskCacheCell,
88    output_format: wgpu::TextureFormat,
89}
90
91impl Renderer {
92    /// Construct a renderer that targets the given color format.
93    ///
94    /// # Errors
95    ///
96    /// Currently infallible; reserved for future pipeline-creation failures.
97    pub fn new(app: &Application, output_format: wgpu::TextureFormat) -> Result<Self, Error> {
98        let triangle = TrianglePipeline::new(app, output_format);
99        let quad = QuadPipeline::new(app, output_format);
100        let sprite = SpritePipeline::new(app, output_format);
101        let graphics = GraphicsPipeline::new(app, output_format);
102        let text = TextPipeline::new(app, output_format);
103        let flex_text = FlexTextPipeline::new(app, output_format);
104        let mesh = MeshPipeline::new(app, output_format);
105        let advanced_blend = advanced_blend::AdvancedBlendPipelines::new(app, output_format);
106        let blit_pipeline = blit::BlitPipeline::new(app, output_format);
107        let clip_pipeline = clip::ClipPipeline::new(app, output_format);
108        let path_clip_pipeline = path_clip::PathClipPipeline::new(app, output_format);
109        let mask_texture_pipeline = mask_texture::MaskTexturePipeline::new(app, output_format);
110        let path_mask_texture_pipeline =
111            path_mask_texture::PathMaskTexturePipeline::new(app, output_format);
112        let mask_compose_pipeline = mask_compose::MaskComposePipeline::new(app, output_format);
113        let mask_combine_pipeline = mask_combine::MaskCombinePipeline::new(app, output_format);
114        Ok(Self {
115            triangle,
116            quad,
117            sprite,
118            graphics,
119            text,
120            flex_text,
121            mesh,
122            advanced_blend,
123            blit: blit_pipeline,
124            clip: clip_pipeline,
125            path_clip: path_clip_pipeline,
126            mask_texture: mask_texture_pipeline,
127            path_mask_texture: path_mask_texture_pipeline,
128            mask_compose: mask_compose_pipeline,
129            mask_combine: mask_combine_pipeline,
130            mask_cache: std::cell::RefCell::new(mask_cache::MaskCache::new()),
131            output_format,
132        })
133    }
134
135    /// Compose two render-textures via an advanced (Tier C) blend mode.
136    ///
137    /// `backdrop` is the previously-rendered destination, `foreground`
138    /// is this node's contribution rendered into its own RT, and the
139    /// composite lands in `output`. All three must share dimensions
140    /// and the format the renderer was constructed against.
141    ///
142    /// # Panics
143    ///
144    /// Panics if `mode` is a *standard* (GPU-native) blend mode — those
145    /// don't have a per-mode pipeline registered. Use the standard
146    /// pipelines (via `render_stage` + `Container::blend_mode`) for
147    /// those.
148    pub fn apply_advanced_blend(
149        &self,
150        app: &Application,
151        mode: crate::blend::BlendMode,
152        backdrop: &RenderTexture,
153        foreground: &RenderTexture,
154        output: &RenderTexture,
155    ) {
156        self.advanced_blend
157            .apply(app, mode, backdrop, foreground, output);
158    }
159
160    /// Clear the target with `clear`, then draw the M0.5 hardcoded triangle.
161    pub fn render(&self, app: &Application, view: &wgpu::TextureView, clear: Color) {
162        Self::with_clearing_pass(app, view, clear, |pass| self.triangle.draw(pass));
163    }
164
165    /// Clear the target with `clear`, then draw a single textured quad.
166    pub fn render_quad(
167        &self,
168        app: &Application,
169        view: &wgpu::TextureView,
170        clear: Color,
171        texture: &Texture,
172        model: glam::Mat4,
173        tint: Color,
174    ) {
175        Self::with_clearing_pass(app, view, clear, |pass| {
176            self.quad.draw(app, pass, texture, model, tint);
177        });
178    }
179
180    /// Clear the target, traverse `stage`, draw every visible node.
181    ///
182    /// Two paths internally:
183    ///
184    /// - **Fast path** (no advanced blend modes AND no clipped
185    ///   containers): one render pass directly into `view`, batching by
186    ///   pipeline + blend mode.
187    /// - **Slow path** (any node uses an advanced blend mode OR has a
188    ///   clip mask set): allocate internal `RenderTexture`s at
189    ///   [`app.width()`/`app.height()`](Application::width), render the
190    ///   scene minus the affected subtrees, then for each affected node
191    ///   render its subtree into a foreground RT, optionally
192    ///   [`apply_clip`](Self::apply_clip) it, and composite onto the
193    ///   in-progress destination (advanced blend modes use
194    ///   [`apply_advanced_blend`](Self::apply_advanced_blend); clipped
195    ///   containers use source-over via the blit pipeline). Final blit
196    ///   to `view`.
197    ///
198    /// Slow-path RT dimensions track `Application::width()` /
199    /// `Application::height()`;
200    /// for views whose dims diverge from the app config, use a matching
201    /// `AppConfig` or pre-render into a fixed-size `RenderTexture`.
202    ///
203    /// Returns [`RenderStats`] with the resulting draw-call and sprite counts.
204    #[must_use]
205    pub fn render_stage(
206        &self,
207        app: &Application,
208        view: &wgpu::TextureView,
209        clear: Color,
210        stage: &Stage,
211    ) -> RenderStats {
212        let dispatched = collect_dispatched_nodes(stage);
213        if dispatched.is_empty() {
214            return self.render_stage_fast(app, view, clear, stage);
215        }
216        self.render_stage_with_advanced_dispatch(app, view, clear, stage, &dispatched)
217    }
218
219    /// Apply a [`MaskShape`] clip to `foreground`, writing the masked
220    /// result to `output`. Pixels outside the mask have their alpha
221    /// zeroed.
222    ///
223    /// Auto-dispatched by `render_stage` when a container's
224    /// [`Container::clip`](crate::scene::Container) is set; this method
225    /// is also exposed for callers who pre-render a foreground RT
226    /// manually and want to mask it without going through the full
227    /// scene-graph path.
228    pub fn apply_clip(
229        &self,
230        app: &Application,
231        shape: crate::scene::clip::MaskShape,
232        foreground: &RenderTexture,
233        output: &RenderTexture,
234    ) {
235        // M-VEC.6: explicit clip primitive routes through the
236        // separated mask + compose path. The auto-dispatch in
237        // `render_stage` keeps using the inline `clip` pipeline (hot
238        // path; refactoring it would add a render pass per dispatched
239        // node every frame).
240        let vector_shape = match shape {
241            MaskShape::Rect { rect } => crate::scene::VectorShape::Rect { rect },
242            MaskShape::RoundedRect { rect, radius } => {
243                crate::scene::VectorShape::RoundedRect { rect, radius }
244            }
245            MaskShape::Circle { center, radius } => {
246                crate::scene::VectorShape::Circle { center, radius }
247            }
248            MaskShape::Ellipse {
249                center,
250                half_extents,
251            } => crate::scene::VectorShape::Ellipse {
252                center,
253                half_extents,
254            },
255        };
256        self.apply_clip_vector(
257            app,
258            &crate::scene::Vector::new(vector_shape),
259            foreground,
260            output,
261        );
262    }
263
264    /// Vector-driven variant of [`Self::apply_clip`] (M-VEC.6 /
265    /// AUT-58). Generates the mask via the M-DYN.1 path (cached) and
266    /// composes against the foreground via [`Self::apply_mask_to_texture`].
267    /// Accepts paths.
268    pub fn apply_clip_vector(
269        &self,
270        app: &Application,
271        vector: &crate::scene::Vector,
272        foreground: &RenderTexture,
273        output: &RenderTexture,
274    ) {
275        let w = foreground.width();
276        let h = foreground.height();
277        let mask_arc = self.cached_vector_mask_texture(app, vector, w, h);
278        self.mask_compose.apply(app, foreground, &mask_arc, output);
279    }
280
281    /// Composition primitive — render `base`, blurred only inside
282    /// `shape`, into `output`. Outside the shape the pixels are
283    /// preserved as-is.
284    ///
285    /// Started life as the AUT-20 rectangle privacy blur; AUT-21
286    /// generalized it to any [`MaskShape`] (rounded rect today;
287    /// ellipse / circle / freehand path follow in AUT-30/-34/-35).
288    /// Calling with `MaskShape::Rect` reproduces the AUT-20 behavior;
289    /// `MaskShape::RoundedRect` redacts with cinematic rounded corners
290    /// matching modern app surfaces.
291    ///
292    /// Pipeline (all RTs match `base`'s dimensions at the renderer's
293    /// output format):
294    ///
295    /// ```text
296    ///   base ─ BlurFilter(radius) ─►  blur_rt
297    ///                                   │
298    ///                                   ├─ ClipPipeline(shape) ─►  masked_rt
299    ///                                   │
300    ///   base ─────────────────────────► output  (Blit::REPLACE)
301    ///                                   │
302    ///   masked_rt ────────────────────► output  (Blit::ALPHA_BLENDING — over)
303    /// ```
304    ///
305    /// `shape` is in NDC `[-1, +1]²` (screen space). `radius` is the
306    /// Gaussian blur radius in pixels; AUT-22 will expose this as a
307    /// scene-data parameter rather than just a method argument.
308    ///
309    /// Use this when you've pre-rendered a frame into `base` (e.g.
310    /// the recording surface) and want to redact a known region.
311    /// Future enhancement: a [`Container`](crate::scene::Container)
312    /// node type that triggers this automatically during scene
313    /// traversal.
314    pub fn apply_privacy_blur(
315        &self,
316        app: &Application,
317        shape: MaskShape,
318        radius: f32,
319        base: &RenderTexture,
320        output: &RenderTexture,
321    ) {
322        // M-VEC.4 refactor: route through the shared vector-mask path
323        // by wrapping the `MaskShape` in a `VectorShape`. Output is
324        // byte-equivalent to the previous inline-clip implementation.
325        let vector_shape = match shape {
326            MaskShape::Rect { rect } => crate::scene::VectorShape::Rect { rect },
327            MaskShape::RoundedRect { rect, radius } => {
328                crate::scene::VectorShape::RoundedRect { rect, radius }
329            }
330            MaskShape::Circle { center, radius } => {
331                crate::scene::VectorShape::Circle { center, radius }
332            }
333            MaskShape::Ellipse {
334                center,
335                half_extents,
336            } => crate::scene::VectorShape::Ellipse {
337                center,
338                half_extents,
339            },
340        };
341        self.apply_privacy_blur_vector(
342            app,
343            &crate::scene::Vector::new(vector_shape),
344            radius,
345            base,
346            output,
347        );
348    }
349
350    /// Vector-driven variant of [`Self::apply_privacy_blur`] (M-VEC.4
351    /// / AUT-56). Same composition shape but the mask is produced
352    /// from a [`Vector`](crate::scene::Vector) instead of a
353    /// [`MaskShape`], unlocking path support and the M-DYN.2 cache
354    /// for repeated regions across frames.
355    ///
356    /// Pipeline:
357    ///
358    /// ```text
359    ///   base ─ BlurFilter(radius) ──────────► blur_rt
360    ///                                              │
361    ///   vector ─ generate_vector_mask_texture ─► mask_rt
362    ///                                              │
363    ///                          (blur_rt × mask_rt) ► masked_rt
364    ///                                              │
365    ///   base ───────────────────────────────────► output  (REPLACE)
366    ///   masked_rt ──────────────────────────────► output  (compose_over)
367    /// ```
368    pub fn apply_privacy_blur_vector(
369        &self,
370        app: &Application,
371        vector: &crate::scene::Vector,
372        radius: f32,
373        base: &RenderTexture,
374        output: &RenderTexture,
375    ) {
376        let mask_arc = self.cached_vector_mask_texture(app, vector, base.width(), base.height());
377        self.compose_blur_through_mask(app, base, radius, &mask_arc, output);
378    }
379
380    /// Composition primitive — render `base`, with `shape` filled by
381    /// a flat `color` (M-MASK / AUT-23 solid redaction). Outside the
382    /// shape the pixels are preserved as-is.
383    ///
384    /// The companion to [`Self::apply_privacy_blur`]. Privacy blur is
385    /// *polish* (the redacted region still has texture); solid
386    /// redaction is *trust* (the region is replaced with an opaque
387    /// fill). Use this for content where partial reconstruction must
388    /// be impossible — API keys, passwords, secrets.
389    ///
390    /// Pipeline:
391    ///
392    /// ```text
393    ///   fill_rt    ← cleared to `color` (RP LoadOp::Clear)
394    ///                                   │
395    ///                                   ├─ ClipPipeline(shape) ─►  masked_rt
396    ///                                   │
397    ///   base ─────────────────────────► output  (Blit::REPLACE)
398    ///                                   │
399    ///   masked_rt ────────────────────► output  (Blit::ALPHA_BLENDING — over)
400    /// ```
401    ///
402    /// Tip: use a fully-opaque `color` (alpha 1.0) for true redaction;
403    /// a partial alpha will let `base` show through proportionally,
404    /// which defeats the "trust" use case.
405    pub fn apply_solid_redaction(
406        &self,
407        app: &Application,
408        shape: MaskShape,
409        color: Color,
410        base: &RenderTexture,
411        output: &RenderTexture,
412    ) {
413        // M-VEC.5 refactor: route through the shared vector-mask
414        // path. Output is byte-equivalent to the previous
415        // inline-clip implementation.
416        let vector_shape = match shape {
417            MaskShape::Rect { rect } => crate::scene::VectorShape::Rect { rect },
418            MaskShape::RoundedRect { rect, radius } => {
419                crate::scene::VectorShape::RoundedRect { rect, radius }
420            }
421            MaskShape::Circle { center, radius } => {
422                crate::scene::VectorShape::Circle { center, radius }
423            }
424            MaskShape::Ellipse {
425                center,
426                half_extents,
427            } => crate::scene::VectorShape::Ellipse {
428                center,
429                half_extents,
430            },
431        };
432        self.apply_solid_redaction_vector(
433            app,
434            &crate::scene::Vector::new(vector_shape),
435            color,
436            base,
437            output,
438        );
439    }
440
441    /// Vector-driven variant of [`Self::apply_solid_redaction`]
442    /// (M-VEC.5 / AUT-57). Same composition shape, but the mask is
443    /// produced from a [`Vector`](crate::scene::Vector) — including
444    /// freehand polygons via [`VectorShape::Path`](crate::scene::VectorShape::Path).
445    pub fn apply_solid_redaction_vector(
446        &self,
447        app: &Application,
448        vector: &crate::scene::Vector,
449        color: Color,
450        base: &RenderTexture,
451        output: &RenderTexture,
452    ) {
453        let format = self.output_format;
454        let w = base.width();
455        let h = base.height();
456        let fill_rt = RenderTexture::with_format(app, w, h, format);
457        let masked_rt = RenderTexture::with_format(app, w, h, format);
458
459        // 1. Clear fill_rt to `color`.
460        let mut encoder = app
461            .device()
462            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
463                label: Some("wisp::redaction fill"),
464            });
465        {
466            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
467                label: Some("wisp::redaction fill pass"),
468                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
469                    view: fill_rt.view(),
470                    resolve_target: None,
471                    ops: wgpu::Operations {
472                        load: wgpu::LoadOp::Clear(wgpu::Color {
473                            r: f64::from(color.r),
474                            g: f64::from(color.g),
475                            b: f64::from(color.b),
476                            a: f64::from(color.a),
477                        }),
478                        store: wgpu::StoreOp::Store,
479                    },
480                })],
481                depth_stencil_attachment: None,
482                timestamp_writes: None,
483                occlusion_query_set: None,
484            });
485        }
486        app.queue().submit(std::iter::once(encoder.finish()));
487
488        // 2. Generate / fetch the mask texture.
489        let mask_arc = self.cached_vector_mask_texture(app, vector, w, h);
490
491        // 3. Compose fill × mask into masked_rt.
492        self.mask_compose
493            .apply(app, &fill_rt, &mask_arc, &masked_rt);
494
495        // 4. Copy base → output, then composite masked redaction over.
496        self.blit.blit(app, base, output.view());
497        self.blit.compose_over(app, &masked_rt, output);
498    }
499
500    /// Combine two alpha-mask textures via a boolean operation
501    /// (M-VEC.11 / AUT-63). The resulting mask is itself a regular
502    /// alpha-mask `RenderTexture` and can drive any downstream
503    /// composition primitive (`apply_mask_to_texture`,
504    /// `compose_blur_through_mask`, etc.).
505    pub fn combine_masks(
506        &self,
507        app: &Application,
508        a: &RenderTexture,
509        b: &RenderTexture,
510        op: mask_combine::MaskCombineOp,
511        output: &RenderTexture,
512    ) {
513        self.mask_combine.apply(app, a, b, op, output);
514    }
515
516    /// Compose privacy blur through an explicit alpha-mask texture
517    /// (M-DYN.3 / AUT-45). Lower-level than [`Self::apply_privacy_blur`]:
518    /// the caller supplies the mask texture, which lets multiple
519    /// effects share one mask without regenerating it. Same composition
520    /// shape as the high-level method:
521    ///
522    /// ```text
523    ///   base ─ BlurFilter(radius) ──► blur_rt
524    ///                                    │
525    ///                  blur_rt × mask  ── masked_rt   ← apply_mask_to_texture
526    ///                                    │
527    ///   base ─────────────────────────► output  (REPLACE)
528    ///   masked_rt ─────────────────────► output  (compose_over)
529    /// ```
530    ///
531    /// Use case: when a region is being privacy-blurred *and*
532    /// solid-redacted *and* spotlighted on the same frame, generate
533    /// the mask once via
534    /// [`Self::cached_vector_mask_texture`](Self::cached_vector_mask_texture)
535    /// and pass the result to all three composition primitives.
536    pub fn compose_blur_through_mask(
537        &self,
538        app: &Application,
539        base: &RenderTexture,
540        radius: f32,
541        mask: &RenderTexture,
542        output: &RenderTexture,
543    ) {
544        let format = self.output_format;
545        let w = base.width();
546        let h = base.height();
547        let blur_rt = RenderTexture::with_format(app, w, h, format);
548        let masked_rt = RenderTexture::with_format(app, w, h, format);
549
550        self.apply_filter(app, &crate::filter::BlurFilter::new(radius), base, &blur_rt);
551        self.mask_compose.apply(app, &blur_rt, mask, &masked_rt);
552        self.blit.blit(app, base, output.view());
553        self.blit.compose_over(app, &masked_rt, output);
554    }
555
556    /// Compose solid-color redaction through an explicit alpha-mask
557    /// texture (M-DYN.4 / AUT-46). Sister of
558    /// [`Self::compose_blur_through_mask`] — same shape, but the
559    /// "what's inside" is a solid color instead of a blur.
560    pub fn compose_solid_through_mask(
561        &self,
562        app: &Application,
563        base: &RenderTexture,
564        color: Color,
565        mask: &RenderTexture,
566        output: &RenderTexture,
567    ) {
568        let format = self.output_format;
569        let w = base.width();
570        let h = base.height();
571        let fill_rt = RenderTexture::with_format(app, w, h, format);
572        let masked_rt = RenderTexture::with_format(app, w, h, format);
573
574        let mut encoder = app
575            .device()
576            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
577                label: Some("wisp::compose_solid fill"),
578            });
579        {
580            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
581                label: Some("wisp::compose_solid fill pass"),
582                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
583                    view: fill_rt.view(),
584                    resolve_target: None,
585                    ops: wgpu::Operations {
586                        load: wgpu::LoadOp::Clear(wgpu::Color {
587                            r: f64::from(color.r),
588                            g: f64::from(color.g),
589                            b: f64::from(color.b),
590                            a: f64::from(color.a),
591                        }),
592                        store: wgpu::StoreOp::Store,
593                    },
594                })],
595                depth_stencil_attachment: None,
596                timestamp_writes: None,
597                occlusion_query_set: None,
598            });
599        }
600        app.queue().submit(std::iter::once(encoder.finish()));
601
602        self.mask_compose.apply(app, &fill_rt, mask, &masked_rt);
603        self.blit.blit(app, base, output.view());
604        self.blit.compose_over(app, &masked_rt, output);
605    }
606
607    /// Compose dim-outside (spotlight) through an explicit *inverted*
608    /// alpha-mask texture (M-DYN.5 / AUT-47). The mask should already
609    /// be inverted (e.g., from
610    /// [`Self::cached_mask_texture_inverted`]); this primitive just
611    /// composes a constant `dim_color` through it.
612    pub fn compose_dim_through_inverted_mask(
613        &self,
614        app: &Application,
615        base: &RenderTexture,
616        dim_color: Color,
617        inverted_mask: &RenderTexture,
618        output: &RenderTexture,
619    ) {
620        // Same as compose_solid_through_mask — the "inverted" part
621        // is purely about how the mask was generated upstream. The
622        // composition itself just multiplies fill × mask.alpha.
623        self.compose_solid_through_mask(app, base, dim_color, inverted_mask, output);
624    }
625
626    /// Compose `foreground × mask.alpha` into `output` (M-VEC.4..6 /
627    /// AUT-56..58 building block). The mask texture must store
628    /// coverage in alpha (the format produced by
629    /// [`Self::generate_mask_texture`] et al).
630    ///
631    /// All three RTs must share dimensions and format. This is the
632    /// primitive that the vector-driven privacy blur / redaction /
633    /// spotlight refactor uses internally; expose it as part of the
634    /// public surface so app-level code (when it lands) can drive
635    /// composition through the same path.
636    pub fn apply_mask_to_texture(
637        &self,
638        app: &Application,
639        foreground: &RenderTexture,
640        mask: &RenderTexture,
641        output: &RenderTexture,
642    ) {
643        self.mask_compose.apply(app, foreground, mask, output);
644    }
645
646    /// Generate an alpha-mask `RenderTexture` for `shape` at
647    /// `(w, h)` (M-DYN.1 / AUT-43). The texture stores coverage as
648    /// `(m, m, m, m)` so consumers can either alpha-multiply (sample
649    /// `.a`) or display as a grayscale silhouette.
650    ///
651    /// This primitive owns *only* coverage. Privacy blur, redaction,
652    /// and spotlight composition layers (M-DYN.3+, M-VEC.4+) consume
653    /// these textures separately. The cache (`AUT-44`) layers on top
654    /// to avoid regenerating identical masks every frame.
655    #[must_use]
656    pub fn generate_mask_texture(
657        &self,
658        app: &Application,
659        shape: MaskShape,
660        w: u32,
661        h: u32,
662    ) -> RenderTexture {
663        self.mask_texture
664            .generate(app, shape, w, h, self.output_format)
665    }
666
667    /// Generate an alpha-mask `RenderTexture` for a
668    /// [`Vector`](crate::scene::Vector) primitive (M-VEC.3 / AUT-55).
669    /// Routes to the analytic-SDF or path-mask path depending on the
670    /// underlying [`VectorShape`](crate::scene::VectorShape).
671    ///
672    /// Only [`Vector::shape`](crate::scene::Vector::shape) is
673    /// consulted — `fill` / `stroke` /
674    /// `opacity` / `transform` don't affect mask coverage.
675    /// `transform` *does* shift the SDF center / path points if
676    /// honored; for V1 we ignore it (the mask is always evaluated in
677    /// NDC against the raw shape data).
678    ///
679    /// This is the bridge used by M-VEC.4..6: any caller that wants
680    /// to drive a mask from vector data uses this entry point, and
681    /// the underlying primitive (privacy blur, redaction, spotlight)
682    /// gets the same alpha texture regardless of which `VectorShape`
683    /// variant produced it.
684    #[must_use]
685    pub fn generate_vector_mask_texture(
686        &self,
687        app: &Application,
688        vector: &crate::scene::Vector,
689        w: u32,
690        h: u32,
691    ) -> RenderTexture {
692        if let Some(mask_shape) = vector.shape.as_mask_shape() {
693            self.generate_mask_texture(app, mask_shape, w, h)
694        } else if let Some(points) = vector.shape.as_path_points() {
695            self.generate_path_mask_texture(app, points, w, h)
696        } else {
697            debug_assert!(false, "VectorShape variant not handled by mask bridge");
698            RenderTexture::with_format(app, w, h, self.output_format)
699        }
700    }
701
702    /// Cached variant of [`Self::generate_vector_mask_texture`].
703    /// Analytic shapes go through the M-DYN.2 cache; path shapes
704    /// bypass (V1: paths not cached — see M-DYN.2's chapter).
705    #[must_use]
706    pub fn cached_vector_mask_texture(
707        &self,
708        app: &Application,
709        vector: &crate::scene::Vector,
710        w: u32,
711        h: u32,
712    ) -> std::sync::Arc<RenderTexture> {
713        if let Some(mask_shape) = vector.shape.as_mask_shape() {
714            self.cached_mask_texture(app, mask_shape, w, h)
715        } else if let Some(points) = vector.shape.as_path_points() {
716            std::sync::Arc::new(self.generate_path_mask_texture(app, points, w, h))
717        } else {
718            debug_assert!(false, "VectorShape variant not handled by mask bridge");
719            std::sync::Arc::new(RenderTexture::with_format(app, w, h, self.output_format))
720        }
721    }
722
723    /// Cached variant of [`Self::generate_mask_texture`] (M-DYN.2 /
724    /// AUT-44). Returns an `Arc<RenderTexture>` that may be shared
725    /// across the cache and other call sites; identical (shape, w,
726    /// h) inputs reuse the same GPU texture across frames instead of
727    /// regenerating.
728    ///
729    /// Cache eviction is FIFO at 64 entries (see the `MAX_ENTRIES`
730    /// constant in `crate::render::mask_cache`). `f32` fields hash by exact bits,
731    /// so callers re-passing the same shape value Just Work.
732    #[must_use]
733    pub fn cached_mask_texture(
734        &self,
735        app: &Application,
736        shape: MaskShape,
737        w: u32,
738        h: u32,
739    ) -> std::sync::Arc<RenderTexture> {
740        let key = mask_cache::MaskKey::new(shape, w, h, false);
741        let mut cache = self.mask_cache.borrow_mut();
742        cache.get_or_insert(key, || {
743            self.mask_texture
744                .generate(app, shape, w, h, self.output_format)
745        })
746    }
747
748    /// Cached + inverted variant.
749    #[must_use]
750    pub fn cached_mask_texture_inverted(
751        &self,
752        app: &Application,
753        shape: MaskShape,
754        w: u32,
755        h: u32,
756    ) -> std::sync::Arc<RenderTexture> {
757        let key = mask_cache::MaskKey::new(shape, w, h, true);
758        let mut cache = self.mask_cache.borrow_mut();
759        cache.get_or_insert(key, || {
760            let rt = RenderTexture::with_format(app, w, h, self.output_format);
761            self.mask_texture.render_into(app, shape, true, &rt);
762            rt
763        })
764    }
765
766    /// Returns `(hits, misses)` for the mask texture cache. Useful
767    /// for tests and observability.
768    #[must_use]
769    pub fn mask_cache_stats(&self) -> (u64, u64) {
770        self.mask_cache.borrow().stats()
771    }
772
773    /// Drop every entry in the mask texture cache. Call when the
774    /// renderer's underlying resources change (e.g., format swap) or
775    /// in tests that want to start from a clean slate.
776    pub fn clear_mask_cache(&self) {
777        self.mask_cache.borrow_mut().clear();
778    }
779
780    /// Inverse variant of [`Self::generate_mask_texture`]: pixels
781    /// outside the shape are opaque, inside are transparent. Used by
782    /// spotlight / dim-outside composition (M-DYN.5).
783    #[must_use]
784    pub fn generate_mask_texture_inverted(
785        &self,
786        app: &Application,
787        shape: MaskShape,
788        w: u32,
789        h: u32,
790    ) -> RenderTexture {
791        let rt = RenderTexture::with_format(app, w, h, self.output_format);
792        self.mask_texture.render_into(app, shape, true, &rt);
793        rt
794    }
795
796    /// Generate an alpha-mask `RenderTexture` for a freehand polygon
797    /// (M-DYN.1 / AUT-43, path variant). Up to 32 vertices honored
798    /// (uniform-buffer cap; same as `apply_path_clip`).
799    #[must_use]
800    pub fn generate_path_mask_texture(
801        &self,
802        app: &Application,
803        points: &[glam::Vec2],
804        w: u32,
805        h: u32,
806    ) -> RenderTexture {
807        self.path_mask_texture
808            .generate(app, points, w, h, self.output_format)
809    }
810
811    /// Apply a freehand polygon mask to `foreground`, writing the
812    /// masked result to `output` (M-MASK / AUT-35).
813    ///
814    /// `points` is a closed polygon in NDC `[-1, +1]²`. Up to
815    /// `MAX_PATH_POINTS` (32) vertices are honored; the rest are
816    /// silently ignored. Pixels inside the polygon pass through
817    /// `foreground` unchanged; pixels outside drop to alpha 0.
818    ///
819    /// Implementation note: the WGSL fragment shader runs a
820    /// crossings-test point-in-polygon at every pixel — no
821    /// tessellation, no SDF. AA is hard-edge for V1; the rasterized
822    /// output is integer-pixel accurate (Jordan curve theorem).
823    pub fn apply_path_clip(
824        &self,
825        app: &Application,
826        points: &[glam::Vec2],
827        foreground: &RenderTexture,
828        output: &RenderTexture,
829    ) {
830        self.path_clip.apply(app, points, false, foreground, output);
831    }
832
833    /// Composition primitive — render `base`, with `points` (a closed
834    /// polygon in NDC) filled by `color` (M-MASK / AUT-35 freehand
835    /// solid redaction). Outside the polygon: base preserved.
836    ///
837    /// Sister to [`Self::apply_solid_redaction`] but with a freehand
838    /// polygon instead of a `MaskShape`. Same four-stage composition
839    /// (clear fill / mask / blit base / compose over) — the only
840    /// difference is the masking pass uses [`Self::apply_path_clip`]
841    /// instead of the SDF clip.
842    pub fn apply_solid_redaction_path(
843        &self,
844        app: &Application,
845        points: &[glam::Vec2],
846        color: Color,
847        base: &RenderTexture,
848        output: &RenderTexture,
849    ) {
850        let format = self.output_format;
851        let fill_rt = RenderTexture::with_format(app, base.width(), base.height(), format);
852        let masked_rt = RenderTexture::with_format(app, base.width(), base.height(), format);
853
854        // 1. Clear fill_rt to color.
855        let mut encoder = app
856            .device()
857            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
858                label: Some("wisp::path_redaction fill"),
859            });
860        {
861            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
862                label: Some("wisp::path_redaction fill pass"),
863                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
864                    view: fill_rt.view(),
865                    resolve_target: None,
866                    ops: wgpu::Operations {
867                        load: wgpu::LoadOp::Clear(wgpu::Color {
868                            r: f64::from(color.r),
869                            g: f64::from(color.g),
870                            b: f64::from(color.b),
871                            a: f64::from(color.a),
872                        }),
873                        store: wgpu::StoreOp::Store,
874                    },
875                })],
876                depth_stencil_attachment: None,
877                timestamp_writes: None,
878                occlusion_query_set: None,
879            });
880        }
881        app.queue().submit(std::iter::once(encoder.finish()));
882
883        // 2. Path-mask the fill.
884        self.path_clip
885            .apply(app, points, false, &fill_rt, &masked_rt);
886
887        // 3. Copy base → output.
888        self.blit.blit(app, base, output.view());
889
890        // 4. Compose masked redaction over base inside output.
891        self.blit.compose_over(app, &masked_rt, output);
892    }
893
894    /// Composition primitive — render `base`, dimmed everywhere
895    /// *outside* `shape` (M-MASK / AUT-28 spotlight, AUT-29 dim
896    /// outside). Inside the shape, pixels are preserved as-is.
897    ///
898    /// `dim_color` is the overlay shade applied outside the shape;
899    /// its alpha controls the dim strength (0 = no effect, 1 = fully
900    /// replaces the surrounding content). For "spotlight a button"
901    /// effects `dim_color = Color::rgba(0.0, 0.0, 0.0, 0.65)` is a
902    /// good cinematic default.
903    ///
904    /// Pipeline (mirrors solid redaction with an *inverted* clip):
905    ///
906    /// ```text
907    ///   fill_rt    ← cleared to `dim_color`
908    ///                                   │
909    ///                                   ├─ ClipPipeline(shape, invert=true) ─►  masked_rt
910    ///                                   │
911    ///   base ─────────────────────────► output  (Blit::REPLACE)
912    ///                                   │
913    ///   masked_rt ────────────────────► output  (Blit::ALPHA_BLENDING — over)
914    /// ```
915    pub fn apply_spotlight(
916        &self,
917        app: &Application,
918        shape: MaskShape,
919        dim_color: Color,
920        base: &RenderTexture,
921        output: &RenderTexture,
922    ) {
923        // M-VEC.6 refactor: route through inverse-mask + compose.
924        let vector_shape = match shape {
925            MaskShape::Rect { rect } => crate::scene::VectorShape::Rect { rect },
926            MaskShape::RoundedRect { rect, radius } => {
927                crate::scene::VectorShape::RoundedRect { rect, radius }
928            }
929            MaskShape::Circle { center, radius } => {
930                crate::scene::VectorShape::Circle { center, radius }
931            }
932            MaskShape::Ellipse {
933                center,
934                half_extents,
935            } => crate::scene::VectorShape::Ellipse {
936                center,
937                half_extents,
938            },
939        };
940        self.apply_spotlight_vector(
941            app,
942            &crate::scene::Vector::new(vector_shape),
943            dim_color,
944            base,
945            output,
946        );
947    }
948
949    /// Vector-driven variant of [`Self::apply_spotlight`] (M-VEC.6 /
950    /// AUT-58). Inverse-mask path: pixels OUTSIDE the shape are
951    /// dimmed by `dim_color`. Accepts paths.
952    pub fn apply_spotlight_vector(
953        &self,
954        app: &Application,
955        vector: &crate::scene::Vector,
956        dim_color: Color,
957        base: &RenderTexture,
958        output: &RenderTexture,
959    ) {
960        let format = self.output_format;
961        let w = base.width();
962        let h = base.height();
963        let fill_rt = RenderTexture::with_format(app, w, h, format);
964        let masked_rt = RenderTexture::with_format(app, w, h, format);
965
966        // 1. Clear fill_rt to dim_color.
967        let mut encoder = app
968            .device()
969            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
970                label: Some("wisp::spotlight fill"),
971            });
972        {
973            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
974                label: Some("wisp::spotlight fill pass"),
975                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
976                    view: fill_rt.view(),
977                    resolve_target: None,
978                    ops: wgpu::Operations {
979                        load: wgpu::LoadOp::Clear(wgpu::Color {
980                            r: f64::from(dim_color.r),
981                            g: f64::from(dim_color.g),
982                            b: f64::from(dim_color.b),
983                            a: f64::from(dim_color.a),
984                        }),
985                        store: wgpu::StoreOp::Store,
986                    },
987                })],
988                depth_stencil_attachment: None,
989                timestamp_writes: None,
990                occlusion_query_set: None,
991            });
992        }
993        app.queue().submit(std::iter::once(encoder.finish()));
994
995        // 2. Generate the *inverse* mask texture (analytic shapes
996        // route through the cached inverted variant; paths are
997        // handled by the path-mask path which has no inverted form
998        // yet — fall back to applying clip-inverted on the existing
999        // pipeline for path shapes).
1000        if let Some(mask_shape) = vector.shape.as_mask_shape() {
1001            let mask_arc = self.cached_mask_texture_inverted(app, mask_shape, w, h);
1002            self.mask_compose
1003                .apply(app, &fill_rt, &mask_arc, &masked_rt);
1004        } else if let Some(points) = vector.shape.as_path_points() {
1005            // Path inverse: generate normal path mask, then use the
1006            // existing path-clip in inverted mode by routing through
1007            // a temporary "inverted-color" mask. Simpler: generate
1008            // mask, then re-apply via inverted blend in mask_compose.
1009            // For V1 we compose the path mask directly and rely on
1010            // path_clip.wgsl's invert flag through the legacy clip.
1011            // This keeps the spotlight-path case working at minimum.
1012            self.path_clip
1013                .apply(app, points, true, &fill_rt, &masked_rt);
1014            // Suppress unused warning about points capture.
1015            let _ = points;
1016        }
1017
1018        // 3. Copy base → output.
1019        self.blit.blit(app, base, output.view());
1020
1021        // 4. Compose dim overlay over the area outside the shape.
1022        self.blit.compose_over(app, &masked_rt, output);
1023    }
1024
1025    /// Convenience wrapper over [`Self::apply_spotlight`] that
1026    /// consumes a [`DimOutside`](crate::scene::DimOutside) data value
1027    /// (M-MASK / AUT-29).
1028    ///
1029    /// Editor inspector controls and persisted documents work with
1030    /// `DimOutside` directly; this method exists so the app side
1031    /// never needs to convert a `DimStrength` enum back to an alpha
1032    /// itself.
1033    pub fn apply_dim_outside_data(
1034        &self,
1035        app: &Application,
1036        dim: &crate::scene::DimOutside,
1037        base: &RenderTexture,
1038        output: &RenderTexture,
1039    ) {
1040        let alpha = dim.strength.alpha();
1041        self.apply_spotlight(
1042            app,
1043            dim.shape,
1044            Color::rgba(0.0, 0.0, 0.0, alpha),
1045            base,
1046            output,
1047        );
1048    }
1049
1050    /// Vector-driven variant of [`Self::apply_dim_outside_data`]
1051    /// (M-VEC.7 / AUT-59). Same composition as `apply_spotlight_vector`
1052    /// but the dim color is derived from a
1053    /// [`DimStrength`](crate::scene::DimStrength) preset
1054    /// (`Light` / `Medium` / `Heavy` / `Custom(alpha)`). Accepts paths.
1055    pub fn apply_dim_outside_vector(
1056        &self,
1057        app: &Application,
1058        vector: &crate::scene::Vector,
1059        strength: crate::scene::DimStrength,
1060        base: &RenderTexture,
1061        output: &RenderTexture,
1062    ) {
1063        let dim_color = Color::rgba(0.0, 0.0, 0.0, strength.alpha());
1064        self.apply_spotlight_vector(app, vector, dim_color, base, output);
1065    }
1066
1067    /// Convenience wrapper over [`Self::apply_privacy_blur`] that
1068    /// consumes a [`PrivacyBlur`](crate::scene::PrivacyBlur) data
1069    /// value (M-MASK / AUT-22).
1070    ///
1071    /// Editor inspector controls and persisted documents work with
1072    /// `PrivacyBlur` structs directly; this method exists so the app
1073    /// side never needs to convert a `BlurStrength` enum back to a raw
1074    /// `f32` itself.
1075    pub fn apply_privacy_blur_data(
1076        &self,
1077        app: &Application,
1078        blur: &crate::scene::PrivacyBlur,
1079        base: &RenderTexture,
1080        output: &RenderTexture,
1081    ) {
1082        self.apply_privacy_blur(app, blur.shape, blur.strength.radius_px(), base, output);
1083    }
1084
1085    /// Fast path: one render pass, no offscreen indirection.
1086    fn render_stage_fast(
1087        &self,
1088        app: &Application,
1089        view: &wgpu::TextureView,
1090        clear: Color,
1091        stage: &Stage,
1092    ) -> RenderStats {
1093        let mut stats = RenderStats::default();
1094        Self::with_clearing_pass(app, view, clear, |pass| {
1095            let (sprite_calls, sprites_drawn) = self.sprite.draw_stage(app, pass, stage);
1096            let (graphics_calls, graphics_drawn) = self.graphics.draw_stage(app, pass, stage);
1097            let (text_calls, glyphs_drawn) = self.text.draw_stage(app, pass, stage);
1098            let (mesh_calls, meshes_drawn) = self.mesh.draw_stage(app, pass, stage);
1099            // FlexText runs LAST so its textured-quad output (typically
1100            // cosmic-text–rasterised chart labels) paints on top of
1101            // every Graphics primitive — the whole point of having a
1102            // separate node type from Sprite.
1103            let (flex_calls, flex_drawn) = self.flex_text.draw_stage(app, pass, stage);
1104            stats.draw_calls = sprite_calls + graphics_calls + text_calls + mesh_calls + flex_calls;
1105            stats.sprites_drawn = sprites_drawn;
1106            stats.graphics_drawn = graphics_drawn;
1107            stats.glyphs_drawn = glyphs_drawn;
1108            stats.meshes_drawn = meshes_drawn;
1109            stats.flex_text_drawn = flex_drawn;
1110        });
1111        stats
1112    }
1113
1114    /// Slow path with auto-dispatch — see [`render_stage`](Self::render_stage).
1115    fn render_stage_with_advanced_dispatch(
1116        &self,
1117        app: &Application,
1118        view: &wgpu::TextureView,
1119        clear: Color,
1120        stage: &Stage,
1121        dispatched: &[crate::scene::NodeId],
1122    ) -> RenderStats {
1123        let (w, h) = (app.width(), app.height());
1124        let format = self.output_format;
1125        let mut dest_a = RenderTexture::with_format(app, w, h, format);
1126        let mut dest_b = RenderTexture::with_format(app, w, h, format);
1127
1128        // Build the exclude set: each dispatched node's subtree is
1129        // handled separately, so the main pass skips them.
1130        let exclude: std::collections::HashSet<crate::scene::NodeId> =
1131            dispatched.iter().copied().collect();
1132
1133        // Phase 1: render the scene, minus the dispatched subtrees,
1134        // into `dest_a`.
1135        let mut stats = self.draw_subtree_to_rt(app, &dest_a, clear, stage, stage.root(), &exclude);
1136
1137        // Phase 2: for each dispatched node in pre-order, render its
1138        // subtree into a fresh foreground RT, optionally apply the
1139        // container's clip, then composite onto the in-progress dest.
1140        // Ping-pong dest_a ↔ dest_b so we don't read+write the same RT
1141        // in one pass.
1142        let foreground = RenderTexture::with_format(app, w, h, format);
1143        let masked = RenderTexture::with_format(app, w, h, format);
1144        let empty_exclude = std::collections::HashSet::new();
1145        for &node_id in dispatched {
1146            let Some(node) = stage.get(node_id) else {
1147                continue;
1148            };
1149            let container = node.container();
1150            let mode = container.blend_mode;
1151            let clip_shape = container.clip;
1152
1153            let sub_stats = self.draw_subtree_to_rt(
1154                app,
1155                &foreground,
1156                Color::rgba(0.0, 0.0, 0.0, 0.0),
1157                stage,
1158                node_id,
1159                &empty_exclude,
1160            );
1161            stats.draw_calls += sub_stats.draw_calls;
1162            stats.sprites_drawn += sub_stats.sprites_drawn;
1163            stats.graphics_drawn += sub_stats.graphics_drawn;
1164            stats.glyphs_drawn += sub_stats.glyphs_drawn;
1165            stats.meshes_drawn += sub_stats.meshes_drawn;
1166            stats.flex_text_drawn += sub_stats.flex_text_drawn;
1167
1168            // If a clip is set, apply it: foreground → masked. Otherwise
1169            // the foreground is the source as-is.
1170            let composite_src = if let Some(shape) = clip_shape {
1171                self.clip.apply(app, shape, &foreground, &masked);
1172                &masked
1173            } else {
1174                &foreground
1175            };
1176
1177            if mode.is_advanced() {
1178                // Advanced blend writes the composite into dest_b, swap.
1179                self.advanced_blend
1180                    .apply(app, mode, &dest_a, composite_src, &dest_b);
1181                std::mem::swap(&mut dest_a, &mut dest_b);
1182            } else {
1183                // Native blend (typically Normal for clip-only nodes):
1184                // source-over composite onto dest_a in place.
1185                self.blit.compose_over(app, composite_src, &dest_a);
1186            }
1187        }
1188
1189        // Phase 3: blit final composited RT to the user-supplied view.
1190        self.blit.blit(app, &dest_a, view);
1191        stats
1192    }
1193
1194    /// Draw a subtree of `stage` (rooted at `start`, skipping `exclude`)
1195    /// into `dest`. Used by both phases of the advanced-dispatch path.
1196    fn draw_subtree_to_rt(
1197        &self,
1198        app: &Application,
1199        dest: &RenderTexture,
1200        clear: Color,
1201        stage: &Stage,
1202        start: crate::scene::NodeId,
1203        exclude: &std::collections::HashSet<crate::scene::NodeId>,
1204    ) -> RenderStats {
1205        let mut stats = RenderStats::default();
1206        Self::with_clearing_pass(app, dest.view(), clear, |pass| {
1207            let (sprite_calls, sprites) =
1208                self.sprite.draw_subtree(app, pass, stage, start, exclude);
1209            let (graphics_calls, graphics) =
1210                self.graphics.draw_subtree(app, pass, stage, start, exclude);
1211            let (text_calls, glyphs) = self.text.draw_subtree(app, pass, stage, start, exclude);
1212            let (mesh_calls, meshes) = self.mesh.draw_subtree(app, pass, stage, start, exclude);
1213            let (flex_calls, flex) = self
1214                .flex_text
1215                .draw_subtree(app, pass, stage, start, exclude);
1216            stats.draw_calls = sprite_calls + graphics_calls + text_calls + mesh_calls + flex_calls;
1217            stats.sprites_drawn = sprites;
1218            stats.graphics_drawn = graphics;
1219            stats.glyphs_drawn = glyphs;
1220            stats.meshes_drawn = meshes;
1221            stats.flex_text_drawn = flex;
1222        });
1223        stats
1224    }
1225
1226    /// Apply `filter` to `input`, writing the final result to `output`.
1227    ///
1228    /// Multi-pass filters allocate a scratch `RenderTexture` (same size +
1229    /// format as `output`) and ping-pong between it and `output`.
1230    pub fn apply_filter(
1231        &self,
1232        app: &Application,
1233        filter: &dyn Filter,
1234        input: &RenderTexture,
1235        output: &RenderTexture,
1236    ) {
1237        let n = filter.passes();
1238        debug_assert!(n >= 1, "Filter::passes() must be >= 1");
1239
1240        let mut encoder = app
1241            .device()
1242            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1243                label: Some("wisp::apply_filter encoder"),
1244            });
1245
1246        if n == 1 {
1247            let mut ctx = FilterContext {
1248                app,
1249                encoder: &mut encoder,
1250            };
1251            filter.render_pass(&mut ctx, input, output, 0);
1252        } else {
1253            let scratch =
1254                RenderTexture::with_format(app, input.width(), input.height(), output.format());
1255            let mut ctx = FilterContext {
1256                app,
1257                encoder: &mut encoder,
1258            };
1259            // pass 0: input → scratch
1260            filter.render_pass(&mut ctx, input, &scratch, 0);
1261            // intermediate passes (rare for our M0.16 filters): scratch → scratch.
1262            // Ping-pong needs two scratches; for n=2 (BlurFilter) we don't hit this.
1263            for p in 1..(n - 1) {
1264                filter.render_pass(&mut ctx, &scratch, &scratch, p);
1265            }
1266            // last pass: scratch → output
1267            filter.render_pass(&mut ctx, &scratch, output, n - 1);
1268        }
1269
1270        app.queue().submit(std::iter::once(encoder.finish()));
1271    }
1272
1273    fn with_clearing_pass(
1274        app: &Application,
1275        view: &wgpu::TextureView,
1276        clear: Color,
1277        draw: impl FnOnce(&mut wgpu::RenderPass<'_>),
1278    ) {
1279        let mut encoder = app
1280            .device()
1281            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1282                label: Some("wisp::Renderer encoder"),
1283            });
1284        {
1285            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1286                label: Some("wisp::Renderer main pass"),
1287                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1288                    view,
1289                    resolve_target: None,
1290                    ops: wgpu::Operations {
1291                        load: wgpu::LoadOp::Clear(wgpu::Color {
1292                            r: f64::from(clear.r),
1293                            g: f64::from(clear.g),
1294                            b: f64::from(clear.b),
1295                            a: f64::from(clear.a),
1296                        }),
1297                        store: wgpu::StoreOp::Store,
1298                    },
1299                })],
1300                depth_stencil_attachment: None,
1301                timestamp_writes: None,
1302                occlusion_query_set: None,
1303            });
1304            draw(&mut pass);
1305        }
1306        app.queue().submit(std::iter::once(encoder.finish()));
1307    }
1308}
1309
1310/// Pre-order walk that returns every visible node which needs the
1311/// slow-path dispatch — either:
1312///
1313/// - the container has an advanced
1314///   [`BlendMode`](crate::blend::BlendMode) (Tier C — Overlay,
1315///   `ColorBurn`, …) requiring an offscreen backdrop sample, or
1316/// - the container has a [`MaskShape`] set in `Container::clip`,
1317///   requiring an offscreen mask pass.
1318///
1319/// Order is pre-order so the auto-dispatch composites in z-order: a
1320/// later dispatched node sees its earlier siblings (and their
1321/// composited results) as the backdrop.
1322fn collect_dispatched_nodes(stage: &Stage) -> Vec<crate::scene::NodeId> {
1323    let mut out = Vec::new();
1324    let mut stack: Vec<crate::scene::NodeId> = vec![stage.root()];
1325    while let Some(id) = stack.pop() {
1326        let Some(node) = stage.get(id) else { continue };
1327        let container = node.container();
1328        if !container.visible {
1329            continue;
1330        }
1331        let needs_dispatch = container.blend_mode.is_advanced() || container.clip.is_some();
1332        if needs_dispatch {
1333            out.push(id);
1334            // Don't recurse — the subtree is rendered separately by the
1335            // dispatcher with the parent's mode/clip applied at composition.
1336            continue;
1337        }
1338        for child in container.children().rev().collect::<Vec<_>>() {
1339            stack.push(child);
1340        }
1341    }
1342    out
1343}