diff --git a/tellur-renderer/examples/timeline_to_mp4.rs b/tellur-renderer/examples/timeline_to_mp4.rs index f7a8c72..69f9640 100644 --- a/tellur-renderer/examples/timeline_to_mp4.rs +++ b/tellur-renderer/examples/timeline_to_mp4.rs @@ -7,9 +7,10 @@ //! distributes the four tracks evenly inside a padded scene with //! `CrossAlign::Stretch`. The dot itself is purely a tree of layout //! containers — `Frame` declares its outer shape and anchors the -//! shadowed circle inside it; `.padding(...)` keeps the dot off the -//! track edges. The `DropShadow` is wrapped directly around the -//! circle, where the shadow conceptually belongs. +//! decorated circle inside it; `.padding(...)` keeps the dot off the +//! track edges. The circle is wrapped in an `Outline` (white stroke) +//! and then a `DropShadow`, so the shadow falls behind the combined +//! stroked shape. use std::path::Path; @@ -23,15 +24,16 @@ use tellur_core::shapes::Circle; use tellur_core::time::{LocalTime, Time}; use tellur_core::timeline::timeline; use tellur_core::vector::Paint; -use tellur_renderer::{DropShadow, FfmpegEncoder, Rasterizable}; +use tellur_renderer::{DropShadow, FfmpegEncoder, Outline, Rasterizable}; /// A circle that triangle-wave scrubs left-to-right-to-left across the /// track's width. `Frame` declares the track's outer shape (fill the /// parent width, fix the height at 60) and anchors the circle so it /// stays fully inside: both `child_anchor` and `at` use the same /// bounce-driven ratio, so the dot's left edge touches the frame's -/// left at `rx = 0` and its right edge touches at `rx = 1`. The whole -/// track is wrapped in a `DropShadow`. +/// left at `rx = 0` and its right edge touches at `rx = 1`. The circle +/// itself is decorated with a white `Outline` and a `DropShadow` — +/// `Outline` runs first so the shadow falls behind the stroked shape. #[raster_component] fn BouncingDot(t: LocalTime) -> impl RasterComponent { let (phase, _) = t.bounce(2.5); @@ -44,14 +46,19 @@ fn BouncingDot(t: LocalTime) -> impl RasterComponent { at: Anchor::new(rx, 0.5), child: DropShadow { offset: Vec2(0.0, 8.0), - blur: 4.0, - color: Color::rgba_u8(255, 255, 255, 100), - child: Circle { - radius, - fill: Paint::Solid(Color::hsl(200.0, 0.7, 0.6)).into(), - stroke: None, + blur: 10.0, + color: Color::rgba_u8(0, 0, 0, 200), + child: Outline { + width: 4.0, + color: Color::rgb_u8(255, 255, 255), + child: Circle { + radius, + fill: Paint::Solid(Color::hsl(200.0, 0.7, 0.6)).into(), + stroke: None, + } + .rasterize() + .boxed(), } - .rasterize() .boxed(), } .boxed(), @@ -68,6 +75,7 @@ fn main() { main_align: MainAlign::SpaceEvenly, cross_align: CrossAlign::Stretch, children: vec![ + BouncingDot { t: t.into() }.boxed(), BouncingDot { t: t.fps(60).into(), } diff --git a/tellur-renderer/src/lib.rs b/tellur-renderer/src/lib.rs index c68490f..d875abf 100644 --- a/tellur-renderer/src/lib.rs +++ b/tellur-renderer/src/lib.rs @@ -1,8 +1,10 @@ +pub mod outline; pub mod rasterize; pub mod render_context; pub mod shadow; pub mod video; +pub use outline::Outline; pub use rasterize::{Rasterizable, Rasterize}; pub use render_context::CachingRenderContext; pub use shadow::DropShadow; diff --git a/tellur-renderer/src/outline.rs b/tellur-renderer/src/outline.rs new file mode 100644 index 0000000..589dcbe --- /dev/null +++ b/tellur-renderer/src/outline.rs @@ -0,0 +1,237 @@ +//! Outline (hard-edge stroke) effect for raster components. +//! +//! Wraps a `RasterComponent` and paints a solid-colored ring around the +//! outside of the child's alpha shape. The ring is produced by dilating +//! the child's alpha by the outline width and subtracting the original +//! alpha, so the stroke never bleeds inside the child. `paint_bounds` +//! expands by `width` so the surrounding `Layer` allocates enough +//! pixels; `layout_box` is left unchanged so outlines do not disturb +//! layout. + +use std::hash::{Hash, Hasher}; + +use bytes::Bytes; +use tellur_core::color::Color; +use tellur_core::composite::composite_at; +use tellur_core::dyn_compare::hash_f32; +use tellur_core::geometry::{Constraints, Rect, Vec2}; +use tellur_core::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; +use tellur_core::render_context::RenderContext; + +pub struct Outline { + /// Stroke width on the outside of the child, in logical units. + pub width: f32, + /// Stroke color (its alpha is multiplied with the ring alpha). + pub color: Color, + pub child: Box, +} + +impl PartialEq for Outline { + fn eq(&self, other: &Self) -> bool { + self.width.to_bits() == other.width.to_bits() + && self.color == other.color + && *self.child == *other.child + } +} + +impl Hash for Outline { + fn hash(&self, state: &mut H) { + hash_f32(self.width, state); + self.color.hash(state); + self.child.hash(state); + } +} + +impl RasterComponent for Outline { + fn layout(&self, constraints: Constraints) -> Vec2 { + self.child.layout(constraints) + } + + fn paint_bounds(&self, size: Vec2) -> Rect { + let inner = self.child.paint_bounds(size); + let extent = self.width.max(0.0); + Rect { + origin: Vec2(inner.origin.0 - extent, inner.origin.1 - extent), + size: Vec2(inner.size.0 + 2.0 * extent, inner.size.1 + 2.0 * extent), + } + } + + fn render(&self, size: Vec2, target: Resolution, ctx: &mut dyn RenderContext) -> RasterImage { + let paint = self.paint_bounds(size); + let child_paint = self.child.paint_bounds(size); + if paint.size.0 <= 0.0 || paint.size.1 <= 0.0 { + return blank_image(target); + } + let sx = target.width as f32 / paint.size.0; + let sy = target.height as f32 / paint.size.1; + + // Render the child through the context so its output is memoized + // independently of the outline — matches the shadow component's + // strategy for static subtrees. + let child_px_w = (child_paint.size.0 * sx).round().max(1.0) as u32; + let child_px_h = (child_paint.size.1 * sy).round().max(1.0) as u32; + let child_image = ctx.render( + self.child.as_ref(), + size, + Resolution::new(child_px_w, child_px_h), + ); + + // Dilate the child alpha by `width` logical units and subtract + // the original alpha so only the ring outside the child + // remains. The dilation radius is computed independently along + // each axis so it stays exactly in lockstep with `paint_bounds` + // (which expands by `width` logical units in both directions); + // otherwise an anisotropic pixel ratio would push the outline + // past the buffer edge and get clipped. + let width_px_x = (self.width.max(0.0) * sx).round() as u32; + let width_px_y = (self.width.max(0.0) * sy).round() as u32; + let outline_image = make_outline(&child_image, width_px_x, width_px_y, self.color); + + let mut accum = vec![0u8; (target.width as usize) * (target.height as usize) * 4]; + + let pad_lu_x = width_px_x as f32 / sx; + let pad_lu_y = width_px_y as f32 / sy; + let outline_local_x = (child_paint.origin.0 - pad_lu_x) - paint.origin.0; + let outline_local_y = (child_paint.origin.1 - pad_lu_y) - paint.origin.1; + let outline_px_x = (outline_local_x * sx).round() as i32; + let outline_px_y = (outline_local_y * sy).round() as i32; + composite_at( + &mut accum, + target, + &outline_image, + outline_px_x, + outline_px_y, + ); + + let child_local_x = child_paint.origin.0 - paint.origin.0; + let child_local_y = child_paint.origin.1 - paint.origin.1; + let child_px_x = (child_local_x * sx).round() as i32; + let child_px_y = (child_local_y * sy).round() as i32; + composite_at(&mut accum, target, &child_image, child_px_x, child_px_y); + + RasterImage { + width: target.width, + height: target.height, + format: PixelFormat::Rgba8, + pixels: Bytes::from(accum), + } + } +} + +fn blank_image(target: Resolution) -> RasterImage { + let bytes = (target.width as usize) * (target.height as usize) * 4; + RasterImage { + width: target.width, + height: target.height, + format: PixelFormat::Rgba8, + pixels: Bytes::from(vec![0u8; bytes]), + } +} + +fn make_outline( + image: &RasterImage, + width_px_x: u32, + width_px_y: u32, + color: Color, +) -> RasterImage { + assert_eq!(image.format, PixelFormat::Rgba8); + let pad_x = width_px_x as usize; + let pad_y = width_px_y as usize; + let in_w = image.width as usize; + let in_h = image.height as usize; + let out_w = in_w + 2 * pad_x; + let out_h = in_h + 2 * pad_y; + + let mut alpha = vec![0u8; out_w * out_h]; + let pixels = image.pixels.as_ref(); + for y in 0..in_h { + for x in 0..in_w { + let src_idx = (y * in_w + x) * 4 + 3; + let dst_idx = (y + pad_y) * out_w + (x + pad_x); + alpha[dst_idx] = pixels[src_idx]; + } + } + + // Dilate the alpha by an elliptical structuring element so the + // outline tracks the shape's curvature. A separable square SE is + // cheaper but visibly flattens curved tips (the top/bottom of a + // circle becomes a horizontal cap); the ellipse keeps the contour + // following the original shape, even when sx ≠ sy. + let dilated = if pad_x > 0 || pad_y > 0 { + dilate_ellipse(&alpha, out_w, out_h, pad_x, pad_y) + } else { + alpha.clone() + }; + + let r = (color.r * 255.0).round().clamp(0.0, 255.0) as u8; + let g = (color.g * 255.0).round().clamp(0.0, 255.0) as u8; + let b = (color.b * 255.0).round().clamp(0.0, 255.0) as u8; + let alpha_scale = color.a.clamp(0.0, 1.0); + + let mut out = Vec::with_capacity(out_w * out_h * 4); + for i in 0..dilated.len() { + // Ring = dilated - original. Saturating sub means pixels fully + // inside the child contribute zero, leaving only the outside + // band. + let ring = dilated[i].saturating_sub(alpha[i]); + let a = ((ring as f32) * alpha_scale).round().clamp(0.0, 255.0) as u8; + out.push(r); + out.push(g); + out.push(b); + out.push(a); + } + + RasterImage { + width: out_w as u32, + height: out_h as u32, + format: PixelFormat::Rgba8, + pixels: Bytes::from(out), + } +} + +/// Morphological dilation by an axis-aligned ellipse with semi-axes +/// `rx` and `ry` (in pixels). For each output pixel, the result is the +/// max of all source pixels `(x+dx, y+dy)` whose offset satisfies +/// `(dx/rx)^2 + (dy/ry)^2 <= 1`. +/// +/// Not separable: the ellipse SE has to be applied as a 2-D +/// neighborhood. Cost is O(W·H·|SE|) ≈ O(W·H·π·rx·ry), which is fine +/// here because the outline is memoized on a per-subtree basis. +fn dilate_ellipse(src: &[u8], w: usize, h: usize, rx: usize, ry: usize) -> Vec { + let mut dst = vec![0u8; w * h]; + if w == 0 || h == 0 || (rx == 0 && ry == 0) { + dst.copy_from_slice(src); + return dst; + } + let rx_i = rx as i64; + let ry_i = ry as i64; + let rx2 = (rx_i * rx_i).max(1); + let ry2 = (ry_i * ry_i).max(1); + let mut offsets: Vec<(i64, i64)> = Vec::new(); + for dy in -ry_i..=ry_i { + for dx in -rx_i..=rx_i { + if dx * dx * ry2 + dy * dy * rx2 <= rx2 * ry2 { + offsets.push((dx, dy)); + } + } + } + let w_i = w as i64; + let h_i = h as i64; + for y in 0..h { + for x in 0..w { + let mut m: u8 = 0; + for &(dx, dy) in &offsets { + let nx = x as i64 + dx; + let ny = y as i64 + dy; + if nx >= 0 && nx < w_i && ny >= 0 && ny < h_i { + let v = src[(ny as usize) * w + (nx as usize)]; + if v > m { + m = v; + } + } + } + dst[y * w + x] = m; + } + } + dst +}