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}