rlvgl_platform/effect.rs
1// SPDX-License-Identifier: MIT
2//! Platform-level effect primitives.
3//!
4//! An [`Effect`] is a stateful, cooperative renderer that contributes
5//! pixels to a target [`Surface`] over one or more frames. Concrete
6//! effect implementations — star crawls, audio scopes, event overlays —
7//! live in higher-level crates. This module fixes the lifecycle contract
8//! a BSP needs to drive one (`activate` / `tick` / `draw` /
9//! `deactivate`), the [`EffectSink`] op vocabulary an effect emits its
10//! frame description through, and the shared parameter shape for
11//! scroll-style crawls.
12//!
13//! ## Why a sink, not a blitter
14//!
15//! An earlier shape of this trait took `paint<B: Blitter>(&mut self,
16//! blitter, dst)`. That binds the effect to a specific rendering path
17//! and makes it impossible to batch or reorder the per-frame work.
18//! [`Effect::draw`] instead takes `&mut dyn EffectSink`: the effect
19//! *describes* its frame as a sequence of ops ([`EffectSink::fill`],
20//! [`EffectSink::blit`], [`EffectSink::blend_a8_row`]) and the sink
21//! decides how to execute them. The shipped sink
22//! [`BlitterSink`] flushes each op synchronously through any
23//! [`Blitter`] (software CPU loops, DMA2D, WGPU); a future
24//! `Dma2dBatchSink` can accumulate ops and fuse adjacent fills into a
25//! single DMA2D transaction, or spread work across ERIF back porches.
26//! None of that leaks into the effect itself — this is the same
27//! command-list / backend split GStreamer uses for its graph execution.
28//!
29//! The trait is object-safe ([`Effect::draw`] uses `&mut dyn
30//! EffectSink`), so hosts can store `Box<dyn Effect>` when they want
31//! polymorphism across effect flavours. For CPU callers who already
32//! hold a [`Blitter`], the [`EffectExt::paint`] convenience wraps the
33//! blitter in a [`BlitterSink`] and calls `draw` in one shot.
34//!
35//! See [`CrawlParams`] for the shared tuning knobs that any text-style
36//! crawl (Star Wars crawl, news ticker, credits roll) can consume.
37
38use crate::blit::{Blitter, PixelFmt, Rect, Surface};
39
40/// Render-op vocabulary an [`Effect`] emits into each frame.
41///
42/// This is the command-list boundary between "what to draw" (the
43/// effect) and "how to draw it" (the sink / backend). Every op is a
44/// self-contained description — the sink can execute it immediately
45/// (see [`BlitterSink`]) or buffer and fuse it with subsequent ops
46/// before dispatching to hardware.
47///
48/// The vocabulary was chosen to cover the existing star-crawl paint
49/// path (jumbo background blit + per-row A8 alpha blend for text),
50/// news-ticker-style patterns (scrolling blit + glyph row blend), and
51/// audio-scope oscillogram patterns (fills + single-row blends).
52/// Additional ops can be added as new effect flavours appear — keep
53/// the additions orthogonal, not "whatever this one effect needs".
54pub trait EffectSink {
55 /// Destination width in pixels as the sink sees it. Effects use
56 /// this to size per-frame work (number of rows to blend, clipping).
57 fn target_width(&self) -> u32;
58
59 /// Destination height in pixels as the sink sees it.
60 fn target_height(&self) -> u32;
61
62 /// Destination pixel format. Effects that only support a subset
63 /// (e.g. ARGB8888) can bail early without emitting ops.
64 fn target_format(&self) -> PixelFmt;
65
66 /// Fill `rect` on the destination with a solid ARGB8888 colour.
67 fn fill(&mut self, rect: Rect, color: u32);
68
69 /// Copy `src_area` from `src` to `dst_pos` on the destination.
70 ///
71 /// Source and destination formats must match what the underlying
72 /// backend supports; the stock [`BlitterSink`] forwards to the
73 /// Blitter's format coverage, DMA2D handles ARGB8888 and RGB565.
74 fn blit(&mut self, src: &Surface<'_>, src_area: Rect, dst_pos: (i32, i32));
75
76 /// Alpha-blend a single A8 row onto the destination with a fixed
77 /// foreground colour.
78 ///
79 /// `alphas` is a contiguous slice of A8 alpha bytes (one per
80 /// pixel). `dst_pos` is the top-left destination corner. Semantics
81 /// match DMA2D's A4/A8 foreground + fixed-colour + destination-fetch
82 /// blend mode: `out = (fg_rgb * alpha + bg_rgb * (255 - alpha)) / 255`
83 /// with `out_alpha = 255`.
84 ///
85 /// Rows come up frequently in text crawls after a perspective-taper
86 /// FIR resample — the effect writes the resampled row into a scratch
87 /// buffer and hands the slice to the sink. Multi-row A8 blends and
88 /// per-pixel colour A8 blends are out of scope for now; add them
89 /// when a second effect needs them.
90 fn blend_a8_row(&mut self, alphas: &[u8], dst_pos: (i32, i32), fg_color: u32);
91}
92
93/// Offset + bounded wrapper around another [`EffectSink`].
94///
95/// All ops are translated by `offset` before being forwarded to the
96/// inner sink, and [`target_width`](EffectSink::target_width) /
97/// [`target_height`](EffectSink::target_height) report the clipped
98/// sub-region. Used by widget-tree hosts (e.g.
99/// `rlvgl_widgets::motion::crawl::CrawlWindow`) to place an effect at a
100/// specific screen location without rewriting every op coordinate
101/// inside the effect. No byte-level clipping — the effect is expected
102/// to respect the reported target dimensions; the wrapper doesn't
103/// intersect rectangles with the sub-region.
104pub struct SubSink<'a> {
105 inner: &'a mut dyn EffectSink,
106 offset: (i32, i32),
107 width: u32,
108 height: u32,
109}
110
111impl<'a> SubSink<'a> {
112 /// Build a sub-sink over `inner` at `offset` with `(width,
113 /// height)` as the reported target size.
114 #[inline]
115 pub fn new(inner: &'a mut dyn EffectSink, offset: (i32, i32), width: u32, height: u32) -> Self {
116 Self {
117 inner,
118 offset,
119 width,
120 height,
121 }
122 }
123}
124
125impl<'a> EffectSink for SubSink<'a> {
126 fn target_width(&self) -> u32 {
127 self.width
128 }
129 fn target_height(&self) -> u32 {
130 self.height
131 }
132 fn target_format(&self) -> PixelFmt {
133 self.inner.target_format()
134 }
135 fn fill(&mut self, rect: Rect, color: u32) {
136 let rect = Rect {
137 x: rect.x + self.offset.0,
138 y: rect.y + self.offset.1,
139 w: rect.w,
140 h: rect.h,
141 };
142 self.inner.fill(rect, color);
143 }
144 fn blit(&mut self, src: &Surface<'_>, src_area: Rect, dst_pos: (i32, i32)) {
145 self.inner.blit(
146 src,
147 src_area,
148 (dst_pos.0 + self.offset.0, dst_pos.1 + self.offset.1),
149 );
150 }
151 fn blend_a8_row(&mut self, alphas: &[u8], dst_pos: (i32, i32), fg_color: u32) {
152 self.inner.blend_a8_row(
153 alphas,
154 (dst_pos.0 + self.offset.0, dst_pos.1 + self.offset.1),
155 fg_color,
156 );
157 }
158}
159
160/// Lifecycle contract for a platform-level visual effect.
161///
162/// Boards (BSPs) drive effects through this trait: start one with
163/// [`Effect::activate`], advance per-frame state with [`Effect::tick`],
164/// describe the current frame with [`Effect::draw`] into an
165/// [`EffectSink`], and tear down with [`Effect::deactivate`].
166/// [`Effect::is_active`] gates whether the host needs to keep calling
167/// `tick` / `draw`.
168///
169/// The trait is object-safe (no generics on any required method) so
170/// hosts can polymorphism-dispatch over `Box<dyn Effect>` when they
171/// want a pool of heterogeneous effects. The generic CPU convenience
172/// [`EffectExt::paint`] lives on a supertrait to preserve that.
173pub trait Effect {
174 /// Returns `true` while this effect is contributing work.
175 ///
176 /// The effect is expected to drop to `false` of its own accord when
177 /// its animation cycle finishes (for example, a crawl scrolling
178 /// past its end). Hosts can also drive the flag down explicitly
179 /// via [`Effect::deactivate`].
180 fn is_active(&self) -> bool;
181
182 /// Prepare internal buffers and start a fresh animation cycle.
183 ///
184 /// Re-activating an already-active effect resets it to the start
185 /// of a new cycle.
186 fn activate(&mut self);
187
188 /// Stop animating.
189 ///
190 /// Subsequent [`Effect::tick`] calls are a no-op until
191 /// [`Effect::activate`] is called again.
192 fn deactivate(&mut self);
193
194 /// Advance per-frame animation state by exactly one frame.
195 ///
196 /// Hosts pace this from their frame clock (wall-clock or VSYNC) so
197 /// that visible motion tracks the declared rate regardless of CPU
198 /// load variations.
199 fn tick(&mut self);
200
201 /// Emit the current frame's render ops into `sink`.
202 ///
203 /// The effect owns no framebuffer memory of its own. The sink owns
204 /// the destination surface (directly, in the case of
205 /// [`BlitterSink`]) or accumulates ops for later flushing (a
206 /// future `Dma2dBatchSink`).
207 fn draw(&mut self, sink: &mut dyn EffectSink);
208}
209
210/// CPU-convenience extension trait: bridge a [`Blitter`] into a
211/// throwaway [`BlitterSink`] and call [`Effect::draw`] in one step.
212///
213/// The generic `paint<B: Blitter>` method is kept here rather than on
214/// [`Effect`] itself so that [`Effect`] stays object-safe. Any type
215/// that implements [`Effect`] automatically gets this method — hosts
216/// keep writing `effect.paint(&mut blitter, &mut dst)` exactly like
217/// before.
218pub trait EffectExt: Effect {
219 /// Paint the current frame into `dst` using `blitter`.
220 ///
221 /// Shorthand for constructing a [`BlitterSink`] over `(blitter,
222 /// dst)` and calling [`Effect::draw`].
223 fn paint<B: Blitter>(&mut self, blitter: &mut B, dst: &mut Surface<'_>) {
224 let mut sink = BlitterSink::new(blitter, dst);
225 self.draw(&mut sink);
226 }
227}
228
229impl<T: Effect + ?Sized> EffectExt for T {}
230
231/// Synchronous [`EffectSink`] that executes each op through a
232/// [`Blitter`] against a caller-supplied destination [`Surface`].
233///
234/// This is the "CPU path" (and the fallback path on every target): no
235/// buffering, no reordering — each `fill` / `blit` / `blend_a8_row`
236/// call forwards straight to the blitter or runs an inline ARGB8888
237/// scanline blend. It's the sink the BBB Linux build uses today, and
238/// it's the right sink on hardware when you want the crawl engine to
239/// drive DMA2D via the [`Blitter`] surface (each DMA2D op is
240/// start+wait so the semantics stay synchronous).
241pub struct BlitterSink<'a, 'b, B: Blitter> {
242 blitter: &'a mut B,
243 dst: &'a mut Surface<'b>,
244}
245
246impl<'a, 'b, B: Blitter> BlitterSink<'a, 'b, B> {
247 /// Build a new sink wrapping `blitter` and borrowing `dst` for the
248 /// lifetime of the sink. The sink has no lifetime beyond a single
249 /// [`Effect::draw`] call; callers rebuild one each frame.
250 #[inline]
251 pub fn new(blitter: &'a mut B, dst: &'a mut Surface<'b>) -> Self {
252 Self { blitter, dst }
253 }
254}
255
256impl<'a, 'b, B: Blitter> EffectSink for BlitterSink<'a, 'b, B> {
257 fn target_width(&self) -> u32 {
258 self.dst.width
259 }
260 fn target_height(&self) -> u32 {
261 self.dst.height
262 }
263 fn target_format(&self) -> PixelFmt {
264 self.dst.format
265 }
266 fn fill(&mut self, rect: Rect, color: u32) {
267 self.blitter.fill(self.dst, rect, color);
268 }
269
270 fn blit(&mut self, src: &Surface<'_>, src_area: Rect, dst_pos: (i32, i32)) {
271 self.blitter.blit(src, src_area, self.dst, dst_pos);
272 }
273
274 fn blend_a8_row(&mut self, alphas: &[u8], dst_pos: (i32, i32), fg_color: u32) {
275 blend_a8_row_inline(self.dst, alphas, dst_pos, fg_color);
276 }
277}
278
279/// CPU implementation of [`EffectSink::blend_a8_row`].
280///
281/// Exposed as a free function so alternate sinks (tests, future
282/// compositors) can reuse the exact byte-for-byte blend without
283/// pulling in the full `BlitterSink` type. The destination format
284/// must be [`PixelFmt::Argb8888`]; A8 sources against other formats
285/// are silently dropped because the blend math assumes 4 bytes per
286/// destination pixel in little-endian BGRA byte order.
287pub fn blend_a8_row_inline(
288 dst: &mut Surface<'_>,
289 alphas: &[u8],
290 dst_pos: (i32, i32),
291 fg_color: u32,
292) {
293 if dst.format != PixelFmt::Argb8888 {
294 return;
295 }
296 let (dx, dy) = dst_pos;
297 if dy < 0 || (dy as u32) >= dst.height {
298 return;
299 }
300 let dst_stride = dst.stride;
301 let dst_w = dst.width as i32;
302 let row_start = (dy as usize) * dst_stride;
303 let fg_r = (fg_color >> 16) & 0xFF;
304 let fg_g = (fg_color >> 8) & 0xFF;
305 let fg_b = fg_color & 0xFF;
306 for (i, &alpha_byte) in alphas.iter().enumerate() {
307 let alpha = alpha_byte as u32;
308 if alpha == 0 {
309 continue;
310 }
311 let x = dx + i as i32;
312 if x < 0 || x >= dst_w {
313 continue;
314 }
315 let dst_off = row_start + (x as usize) * 4;
316 if dst_off + 4 > dst.buf.len() {
317 break;
318 }
319 let bg_b = dst.buf[dst_off] as u32;
320 let bg_g = dst.buf[dst_off + 1] as u32;
321 let bg_r = dst.buf[dst_off + 2] as u32;
322 let inv = 255 - alpha;
323 dst.buf[dst_off] = ((fg_b * alpha + bg_b * inv) / 255) as u8;
324 dst.buf[dst_off + 1] = ((fg_g * alpha + bg_g * inv) / 255) as u8;
325 dst.buf[dst_off + 2] = ((fg_r * alpha + bg_r * inv) / 255) as u8;
326 dst.buf[dst_off + 3] = 0xFF;
327 }
328}
329
330/// Common tuning parameters for a text-style scroll crawl.
331///
332/// Concrete presets (the disco-demo star crawl, a news ticker, …) fill
333/// these in. The platform crate doesn't mandate a specific rendering
334/// style — only a shared shape so BSPs can treat crawls interchangeably
335/// and so rate / spacing / perspective tuning is lifted out of ad-hoc
336/// per-board constants.
337///
338/// The `viewport_w` / `viewport_h` fields describe the target rendering
339/// region — what the effect paints into — and are used by callers to
340/// size jumbo / text-source / scanline buffers before handing them to
341/// the factory. They are not consumed by the effect itself.
342#[derive(Copy, Clone, Debug, PartialEq, Eq)]
343pub struct CrawlParams {
344 /// Target viewport width in pixels.
345 pub viewport_w: u32,
346 /// Target viewport height in pixels.
347 pub viewport_h: u32,
348 /// Host frame rate in Hz. Effects use this together with
349 /// `pixels_per_sec` to derive the per-frame scroll increment.
350 pub frame_hz: u32,
351 /// Declared wall-clock scroll speed, in pixels per second.
352 pub pixels_per_sec: u32,
353 /// Inter-line spacing in pixels for pre-rendered text.
354 pub line_spacing_px: u16,
355 /// Text colour in ARGB8888 byte order.
356 pub text_color_argb: u32,
357 /// Divider applied to the text scroll accumulator when sampling
358 /// the background. `1` locks the background to the text; higher
359 /// values create a parallax effect.
360 pub background_scroll_divisor: u16,
361 /// Projected text width at the top edge of the viewport.
362 pub perspective_top_width: u16,
363 /// Projected text width at the bottom edge of the viewport.
364 pub perspective_bottom_width: u16,
365}
366
367impl CrawlParams {
368 /// Disco-demo star-crawl defaults: 40 px/s upward scroll, 36 px
369 /// line spacing, 360→600 px perspective taper, canonical yellow
370 /// text, background drifting at 1/3 the text speed.
371 ///
372 /// Matches the STM32H747I-DISCO hardware star crawl and the BBB
373 /// Linux port. Hosts that want the sim-style looser spacing can
374 /// mutate `line_spacing_px` after construction.
375 pub const fn star_crawl_disco(viewport_w: u32, viewport_h: u32, frame_hz: u32) -> Self {
376 Self {
377 viewport_w,
378 viewport_h,
379 frame_hz,
380 pixels_per_sec: 40,
381 line_spacing_px: 36,
382 text_color_argb: 0xFFFF_D700,
383 background_scroll_divisor: 3,
384 perspective_top_width: 360,
385 perspective_bottom_width: 600,
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn star_crawl_disco_matches_hardware_baseline() {
396 let p = CrawlParams::star_crawl_disco(720, 480, 57);
397 assert_eq!(p.viewport_w, 720);
398 assert_eq!(p.viewport_h, 480);
399 assert_eq!(p.frame_hz, 57);
400 assert_eq!(p.pixels_per_sec, 40);
401 assert_eq!(p.line_spacing_px, 36);
402 assert_eq!(p.text_color_argb, 0xFFFF_D700);
403 assert_eq!(p.background_scroll_divisor, 3);
404 assert_eq!(p.perspective_top_width, 360);
405 assert_eq!(p.perspective_bottom_width, 600);
406 }
407
408 #[test]
409 fn crawl_params_is_copy() {
410 let a = CrawlParams::star_crawl_disco(720, 480, 57);
411 let b = a;
412 assert_eq!(a, b);
413 }
414}