Skip to main content

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}