frust_scene/scene.rs
1//! The [`Scene`] display list and the [`Command`]s it holds.
2
3use kurbo::{Affine, BezPath, Point, Rect};
4use peniko::{Brush, Color, ImageData};
5
6use crate::glyph::GlyphRun;
7use crate::shader::ShaderProgram;
8
9/// Per-corner radii for a rounded rectangle, in the pre-transform coordinate
10/// space.
11///
12/// Corners are named clockwise from the top-left, matching
13/// `kurbo::RoundedRectRadii` — the concrete shape `frust-render` builds at
14/// encode time (scene-layer purity keeps the shape type itself out of this
15/// crate's commands).
16///
17/// `From<f64>` covers the uniform case, so the one-radius callers that predate
18/// this type keep passing a bare `f64` and the builder converts:
19/// `CornerRadii::from(8.0)` == `CornerRadii::new(8.0, 8.0, 8.0, 8.0)`.
20#[derive(Clone, Copy, Debug, Default, PartialEq)]
21pub struct CornerRadii {
22 /// Top-left corner radius.
23 pub top_left: f64,
24 /// Top-right corner radius.
25 pub top_right: f64,
26 /// Bottom-right corner radius.
27 pub bottom_right: f64,
28 /// Bottom-left corner radius.
29 pub bottom_left: f64,
30}
31
32impl CornerRadii {
33 /// Radii for each corner, clockwise from the top-left (the
34 /// `kurbo::RoundedRectRadii::new` argument order).
35 pub const fn new(top_left: f64, top_right: f64, bottom_right: f64, bottom_left: f64) -> Self {
36 Self {
37 top_left,
38 top_right,
39 bottom_right,
40 bottom_left,
41 }
42 }
43
44 /// The same `radius` on all four corners — what [`From<f64>`] builds.
45 pub const fn uniform(radius: f64) -> Self {
46 Self::new(radius, radius, radius, radius)
47 }
48
49 /// The largest of the four corner radii.
50 ///
51 /// A backend that can only express a single radius (both blurred-shadow
52 /// primitives take one) lowers through this rather than through the
53 /// smallest: a shadow rounded *more* than its caster only lightens a square
54 /// corner, while one rounded less pushes a hard shadow wedge out through a
55 /// rounded corner's notch.
56 pub fn largest(&self) -> f64 {
57 self.top_left
58 .max(self.top_right)
59 .max(self.bottom_right)
60 .max(self.bottom_left)
61 }
62}
63
64impl From<f64> for CornerRadii {
65 fn from(radius: f64) -> Self {
66 Self::uniform(radius)
67 }
68}
69
70/// A stroke's dash pattern: an `on` run, an `off` gap, and a `phase` offset
71/// into that repeating cycle — all lengths in the pre-transform coordinate
72/// space.
73///
74/// One on/off pair rather than an arbitrary-length array: it covers the dashed
75/// dividers/outlines/focus rings widgets ask for, stays `Copy` (so
76/// [`PathStyle`] and every command holding one stay `Copy`/allocation-free),
77/// and the render crate expands it into a `[on, off]` slice at encode time.
78#[derive(Clone, Copy, Debug, PartialEq)]
79pub struct DashPattern {
80 /// Length of each painted dash.
81 pub on: f64,
82 /// Length of each gap between dashes.
83 pub off: f64,
84 /// Distance into the on/off cycle the pattern starts at — animate this to
85 /// march the dashes along the path.
86 pub phase: f64,
87}
88
89/// Minimum total dash period (on + off) in logical pixels for a pattern to be
90/// rendered as dashed. Below this, the dash segments would be too small to see
91/// and the period becomes too dense to efficiently expand at encode time, so
92/// the pattern falls back to a solid stroke. This is the threshold below which
93/// a dash pattern is visually indistinguishable from a solid stroke anyway.
94const DASH_PERIOD_EPSILON: f64 = 0.1;
95
96impl DashPattern {
97 /// An `on`/`off` cycle starting at phase 0.
98 pub const fn new(on: f64, off: f64) -> Self {
99 Self {
100 on,
101 off,
102 phase: 0.0,
103 }
104 }
105
106 /// The same pattern offset by `phase` into its cycle.
107 pub const fn with_phase(mut self, phase: f64) -> Self {
108 self.phase = phase;
109 self
110 }
111
112 /// Whether this pattern actually breaks a stroke into dashes.
113 ///
114 /// A non-positive or non-finite length has no dashed interpretation (a zero
115 /// `off` is a solid line; a zero `on` paints nothing, which a caller never
116 /// means by "dashed"), so `frust-render` strokes such a path solid rather
117 /// than feeding a degenerate cycle to the dash iterator. Additionally, a
118 /// period (on + off) smaller than [`DASH_PERIOD_EPSILON`] is too dense to
119 /// efficiently expand at encode time and is visually indistinguishable from
120 /// a solid stroke anyway, so the pattern falls back to solid.
121 pub fn is_effective(&self) -> bool {
122 self.on.is_finite()
123 && self.off.is_finite()
124 && self.phase.is_finite()
125 && self.on > 0.0
126 && self.off > 0.0
127 && (self.on + self.off) >= DASH_PERIOD_EPSILON
128 }
129}
130
131/// How a [`Command::Path`] is rendered: filled or stroked.
132///
133/// Kept intentionally minimal: the fill/stroke shape
134/// widgets need for arcs (circular progress, activity indicators) — a
135/// nonzero-fill, or a stroke with a fixed width, round caps/joins, and an
136/// optional [`DashPattern`]. No miter limit or even-odd fill rule yet; extend
137/// here (and in `frust-render::convert`) if a later widget needs one.
138#[derive(Clone, Copy, Debug, PartialEq)]
139pub enum PathStyle {
140 /// Fill using the nonzero winding rule.
141 Fill,
142 /// Stroke with the given width (pre-transform coordinate space) and
143 /// round caps/joins.
144 Stroke {
145 /// Stroke width, in the pre-transform coordinate space.
146 width: f64,
147 /// Dash pattern, or `None` for a solid stroke — what every stroke that
148 /// predates dashing records, and what the render crate falls back to
149 /// for a degenerate pattern (see [`DashPattern::is_effective`]).
150 dash: Option<DashPattern>,
151 },
152}
153
154/// A single paint operation recorded into a [`Scene`].
155///
156/// This is the renderer-agnostic vocabulary the render crate (layer 4)
157/// translates into backend draw calls. No `vello`/`wgpu` types appear here.
158#[derive(Clone, Debug)]
159pub enum Command {
160 /// Fill an axis-aligned rectangle with a brush, under a transform.
161 FillRect {
162 rect: Rect,
163 brush: Brush,
164 transform: Affine,
165 },
166 /// Fill an axis-aligned rectangle with rounded corners.
167 RoundedRect {
168 rect: Rect,
169 /// Per-corner radii, in the pre-transform coordinate space; a uniform
170 /// radius arrives here as [`CornerRadii::uniform`].
171 radii: CornerRadii,
172 brush: Brush,
173 transform: Affine,
174 },
175 /// Stroke a straight line segment from `p0` to `p1`.
176 Line {
177 p0: Point,
178 p1: Point,
179 /// Stroke width, in the pre-transform coordinate space.
180 width: f64,
181 brush: Brush,
182 transform: Affine,
183 },
184 /// Draw a positioned run of glyphs.
185 GlyphRun(GlyphRun),
186 /// Push a rectangular clip onto the render backend's clip stack, under a
187 /// transform. Subsequent draws are clipped to it until the matching
188 /// [`Command::PopClip`].
189 PushClip { rect: Rect, transform: Affine },
190 /// Push a clip with rounded corners onto the render backend's clip stack,
191 /// under a transform. Subsequent draws are clipped to the rounded shape
192 /// until the matching [`Command::PopClip`].
193 ///
194 /// Popped by the *same* [`Command::PopClip`] a [`Command::PushClip`] uses —
195 /// there is one clip stack, not a separate rounded one. The motivating
196 /// consumer is a radiused mask over a bitmap (an avatar/thumbnail), which a
197 /// rectangular clip cannot express.
198 ///
199 /// Carried as a `Rect` + [`CornerRadii`], mirroring
200 /// [`Command::RoundedRect`] rather than naming a `kurbo::RoundedRect`; the
201 /// render crate reconstitutes the concrete shape at encode time. An
202 /// arbitrary-path clip is deliberately not modelled yet — extend here (and
203 /// in `frust-render::convert`) if one is ever needed.
204 PushClipRounded {
205 rect: Rect,
206 /// Per-corner radii, in the pre-transform coordinate space.
207 radii: CornerRadii,
208 transform: Affine,
209 },
210 /// Pop the most recently pushed clip.
211 PopClip,
212 /// Draw a decoded image (natural pixel size `data.width`x`data.height`),
213 /// scaled to fill `dest`, under a transform.
214 ///
215 /// `data` is cloned from the widget's cached `ImageSource` each frame;
216 /// `peniko::ImageData`'s `Blob<u8>` is reference-counted internally, so
217 /// this is a cheap handle clone, never a pixel copy or re-decode.
218 Image {
219 data: ImageData,
220 dest: Rect,
221 transform: Affine,
222 },
223 /// Draw a rounded rectangle with a gaussian-blurred elevation shadow (an
224 /// approximation of a CSS `box-shadow`), under a transform.
225 ///
226 /// `radii` are the rectangle's corner radii, `std_dev` the blur's standard
227 /// deviation, both in the pre-transform coordinate space. Maps onto vello
228 /// 0.9's `Scene::draw_blurred_rounded_rect`, whose `brush` parameter is a
229 /// concrete `peniko::Color` (not a `Brush`) — a blurred shadow has no
230 /// gradient support in this vello version — and which takes a *single*
231 /// radius, as does the CPU tier's `fill_blurred_rounded_rect`. A per-corner
232 /// shadow therefore lowers through [`CornerRadii::largest`] at encode time
233 /// (see `frust-render::convert`); the field carries all four corners so
234 /// this command shares one radii vocabulary with its rounded siblings and
235 /// gains per-corner blur for free if a backend ever grows it.
236 BlurredRoundedRect {
237 rect: Rect,
238 radii: CornerRadii,
239 std_dev: f64,
240 color: Color,
241 transform: Affine,
242 },
243 /// Push a translucent layer onto the render backend's layer stack, under
244 /// a transform. Subsequent draws are composited at `alpha` until the
245 /// matching [`Command::PopLayer`].
246 ///
247 /// Semantically a generalization of [`Command::PushClip`] (which is
248 /// `PushLayer` with `alpha: 1.0`) — kept as a distinct variant rather than
249 /// folded into it so existing `PushClip`/`PopClip` consumers are
250 /// unaffected (see `frust-render::convert`).
251 PushLayer {
252 rect: Rect,
253 alpha: f32,
254 transform: Affine,
255 },
256 /// Pop the most recently pushed layer.
257 PopLayer,
258 /// Clear an axis-aligned rectangle to full transparency (alpha 0) under a
259 /// transform, erasing everything already drawn beneath it in this scene —
260 /// a real destination-clearing composite, not a skipped paint.
261 ///
262 /// The platform-view hole-punch is the sole v1 producer (see
263 /// `frust-core`'s `PaintScene::clear_rect`): a translucent-surface (Mode B)
264 /// slot punches its rect so an opaque app backdrop painted below it (the
265 /// catalog's `AppBackground`) doesn't seal the hole the hosted native view
266 /// shows through. `frust-render::convert` lowers this to a destination-out
267 /// composite (an opaque fill erasing color and alpha wherever it covers),
268 /// hoisted to the scene root past any enclosing clip/opacity group so a
269 /// nested slot's punch isn't confined to its own group's content — the
270 /// exact composite mode and hoist mechanics are `frust-render`'s to name
271 /// (scene-layer purity: no vello types here). The clear only becomes
272 /// visible on a surface that actually carries an alpha channel; on an
273 /// opaque surface the transparency is disregarded (vello's surface
274 /// contract), which is why the producer gates it on the translucent flag
275 /// rather than punching always.
276 ClearRect { rect: Rect, transform: Affine },
277 /// Fill or stroke an arbitrary vector path (e.g. an arc), under a
278 /// transform.
279 ///
280 /// `path` is a `kurbo::BezPath` already positioned in the same
281 /// coordinate space as every other command (the caller has translated it
282 /// to the widget's origin before recording); `style` selects fill vs.
283 /// stroke (see [`PathStyle`]).
284 Path {
285 path: BezPath,
286 style: PathStyle,
287 brush: Brush,
288 transform: Affine,
289 },
290 /// Draw a fragment-shader-filled rectangle, scaled to fill `dest`, under
291 /// a transform.
292 ///
293 /// `program` is compiled (and cache-keyed on [`ShaderProgram::id`]) by
294 /// `frust-engine`'s effects module. Output is treated as premultiplied
295 /// alpha. `time` is seconds, app-supplied (from `PaintCtx::frame_time` at
296 /// the widget layer), threaded into the shader's uniform buffer. This
297 /// command lives at the scene layer to preserve purity: the render backend
298 /// interprets the compiled shader output.
299 ShaderQuad {
300 program: ShaderProgram,
301 dest: Rect,
302 transform: Affine,
303 time: f32,
304 },
305 /// Marks the start of a cacheable "snapshot" bracket, under a transform.
306 ///
307 /// The commands between this and the matching [`Command::PopSnapshot`] are
308 /// the BODY, recorded in the ordinary composed transform space (i.e. not
309 /// pre-multiplied by `scale`). `rect` is the body's bounds in the local
310 /// space of `transform` — the same convention [`Command::PushLayer`]'s
311 /// `rect`/`alpha`/`transform` use.
312 ///
313 /// `alpha` (`0.0..=1.0`) and `scale` (uniform, about `rect`'s center) are
314 /// PRESENTATION parameters applied to the body as a whole — deliberately
315 /// NOT baked into the body's own commands, so a renderer can reuse a
316 /// rasterized body while they animate.
317 ///
318 /// A renderer that implements snapshots MAY rasterize the body once and
319 /// draw it as an image with `transform * scale_about(rect.center(),
320 /// scale)` inside an alpha layer. A renderer that does not MUST paint the
321 /// body inline wrapped exactly as if the recorder had emitted
322 /// `push_transform(scale_about(rect.center(), scale))` (when `scale !=
323 /// 1.0`) then `push_layer(rect, alpha)` (when `alpha < 1.0`) — pops
324 /// reversed (see `frust-render::convert`'s miss path).
325 ///
326 /// Syntactic nesting is allowed, but only the OUTERMOST bracket needs
327 /// honouring — an inner bracket's own `alpha`/`scale` may be ignored by a
328 /// non-implementing renderer. An unbalanced [`Command::PopSnapshot`] is
329 /// ignored, the same policy [`Command::PopLayer`] follows.
330 PushSnapshot {
331 /// Cache key identifying this bracket's body across frames.
332 key: u64,
333 rect: Rect,
334 alpha: f32,
335 /// Uniform scale, applied about `rect`'s center.
336 scale: f64,
337 transform: Affine,
338 },
339 /// Pop the most recently pushed snapshot bracket (see
340 /// [`Command::PushSnapshot`]).
341 PopSnapshot,
342 /// Draw an externally owned GPU texture scaled to fill `dest` under
343 /// `transform`.
344 ///
345 /// `id` is opaque scene-layer data (precedent: [`ShaderProgram`]'s opaque
346 /// id, `shader.rs`) — only the render backend resolves it against
347 /// textures registered with the GPU context; an unregistered id draws
348 /// nothing.
349 SceneTexture {
350 id: u64,
351 dest: Rect,
352 transform: Affine,
353 },
354}
355
356/// Renderer-agnostic, immediate-mode display list.
357///
358/// Widgets paint into a `Scene` (via [`crate::SceneBuilder`]) each frame; the
359/// render crate consumes [`Scene::commands`] to draw. Rebuilt per frame — call
360/// [`Scene::reset`] before re-recording rather than allocating a new `Scene`.
361#[derive(Clone, Debug, Default)]
362pub struct Scene {
363 commands: Vec<Command>,
364 /// [`crate::SceneBuilder`]'s transform stack, parked here between frames
365 /// so its backing `Vec` allocation is
366 /// reused across every `SceneBuilder::new` call instead of reallocating
367 /// per frame — a `SceneBuilder` borrows it via `&mut Scene` and resets it
368 /// to `[Affine::IDENTITY]` on construction, so behavior is unchanged.
369 /// Not part of the stable widget-facing API.
370 pub(crate) transform_stack: Vec<Affine>,
371 /// Depth counter for open [`Command::PushSnapshot`] brackets, incremented
372 /// by [`crate::SceneBuilder::push_snapshot`] and decremented — only when
373 /// greater than zero — by [`crate::SceneBuilder::pop_snapshot`], the same
374 /// unbalanced-pop policy [`Command::PopClip`]/[`Command::PopLayer`]
375 /// follow. Reset alongside `commands` in [`Scene::reset`]; NOT reset by
376 /// [`crate::SceneBuilder::new`] (unlike `transform_stack`), since it
377 /// tracks bracket balance across the whole scene, not a builder session.
378 /// Not part of the stable widget-facing API.
379 pub(crate) snapshot_depth: usize,
380}
381
382impl Scene {
383 /// Creates an empty scene.
384 pub fn new() -> Self {
385 Self::default()
386 }
387
388 /// Clears all recorded commands so the scene can be reused for the next frame.
389 pub fn reset(&mut self) {
390 self.commands.clear();
391 self.snapshot_depth = 0;
392 }
393
394 /// The recorded commands for this frame, in paint order.
395 pub fn commands(&self) -> &[Command] {
396 &self.commands
397 }
398
399 /// Records a command. Used by [`crate::SceneBuilder`]; not part of the
400 /// stable widget-facing API.
401 pub(crate) fn push(&mut self, command: Command) {
402 self.commands.push(command);
403 }
404
405 /// How many [`Command::PushSnapshot`] brackets are still open.
406 ///
407 /// `#[doc(hidden)]`, and deliberately not part of the stable widget-facing
408 /// API: a widget has no business reading the bracket depth mid-recording,
409 /// and nothing in the framework branches on it. It exists so a property
410 /// test outside this crate can assert the balance contract the field's own
411 /// docs state — that a balanced sequence leaves the depth at zero and an
412 /// unmatched [`crate::SceneBuilder::pop_snapshot`] never drives it below
413 /// zero — which is otherwise unobservable from the command stream alone,
414 /// since an ignored pop records nothing to observe.
415 #[doc(hidden)]
416 pub fn snapshot_depth(&self) -> usize {
417 self.snapshot_depth
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn new_scene_has_no_commands() {
427 let scene = Scene::new();
428 assert!(scene.commands().is_empty());
429 }
430
431 #[test]
432 fn reset_clears_commands() {
433 let mut scene = Scene::new();
434 scene.push(Command::PushClip {
435 rect: Rect::new(0.0, 0.0, 1.0, 1.0),
436 transform: Affine::IDENTITY,
437 });
438 assert_eq!(scene.commands().len(), 1);
439
440 scene.reset();
441 assert!(scene.commands().is_empty());
442 }
443
444 #[test]
445 fn reset_clears_snapshot_depth() {
446 let mut scene = Scene::new();
447 scene.snapshot_depth = 2;
448 scene.reset();
449 assert_eq!(scene.snapshot_depth, 0);
450 }
451
452 #[test]
453 fn corner_radii_from_f64_is_uniform() {
454 assert_eq!(CornerRadii::from(6.0), CornerRadii::new(6.0, 6.0, 6.0, 6.0));
455 assert_eq!(CornerRadii::from(6.0), CornerRadii::uniform(6.0));
456 }
457
458 #[test]
459 fn corner_radii_new_orders_corners_clockwise_from_top_left() {
460 // The argument order is load-bearing: it must match
461 // `kurbo::RoundedRectRadii::new`, which `frust-render` converts into.
462 let radii = CornerRadii::new(1.0, 2.0, 3.0, 4.0);
463 assert_eq!(radii.top_left, 1.0);
464 assert_eq!(radii.top_right, 2.0);
465 assert_eq!(radii.bottom_right, 3.0);
466 assert_eq!(radii.bottom_left, 4.0);
467 }
468
469 #[test]
470 fn corner_radii_largest_picks_the_biggest_corner() {
471 assert_eq!(CornerRadii::new(1.0, 9.0, 3.0, 4.0).largest(), 9.0);
472 assert_eq!(CornerRadii::uniform(2.0).largest(), 2.0);
473 assert_eq!(CornerRadii::default().largest(), 0.0);
474 }
475
476 #[test]
477 fn dash_pattern_defaults_to_zero_phase_and_carries_with_phase() {
478 let dash = DashPattern::new(4.0, 2.0);
479 assert_eq!(dash.phase, 0.0);
480 assert_eq!(dash.with_phase(1.5).phase, 1.5);
481 // `with_phase` leaves the cycle itself alone.
482 assert_eq!(dash.with_phase(1.5).on, 4.0);
483 assert_eq!(dash.with_phase(1.5).off, 2.0);
484 }
485
486 #[test]
487 fn degenerate_dash_patterns_are_not_effective() {
488 assert!(DashPattern::new(4.0, 2.0).is_effective());
489 assert!(!DashPattern::new(0.0, 2.0).is_effective());
490 assert!(!DashPattern::new(4.0, 0.0).is_effective());
491 assert!(!DashPattern::new(-4.0, 2.0).is_effective());
492 assert!(!DashPattern::new(f64::NAN, 2.0).is_effective());
493 assert!(!DashPattern::new(f64::INFINITY, 2.0).is_effective());
494 assert!(
495 !DashPattern::new(4.0, 2.0)
496 .with_phase(f64::NAN)
497 .is_effective()
498 );
499 }
500
501 #[test]
502 fn tiny_period_dash_patterns_are_not_effective() {
503 // A pattern with a vanishingly small period (on + off) below the
504 // epsilon is not effective, falling back to solid stroke. This prevents
505 // the dash iterator from expanding into astronomically many segments.
506 assert!(!DashPattern::new(1e-9, 1e-9).is_effective());
507 assert!(!DashPattern::new(0.01, 0.01).is_effective()); // period 0.02 < 0.1
508 assert!(!DashPattern::new(0.04, 0.05).is_effective()); // period 0.09 < 0.1
509 }
510
511 #[test]
512 fn dash_pattern_at_epsilon_boundary_is_effective() {
513 // At the epsilon boundary, the pattern is exactly effective (not strict <).
514 assert!(DashPattern::new(0.05, 0.05).is_effective()); // period exactly 0.1
515 assert!(DashPattern::new(0.03, 0.07).is_effective()); // period exactly 0.1
516 }
517
518 #[test]
519 fn normal_dash_patterns_remain_effective() {
520 // Normal-sized patterns well above epsilon remain effective.
521 assert!(DashPattern::new(1.0, 0.5).is_effective());
522 assert!(DashPattern::new(4.0, 2.0).is_effective());
523 assert!(DashPattern::new(10.0, 10.0).is_effective());
524 }
525}