Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 78 additions & 14 deletions crates/rustmotion/src/engine/render/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,12 @@ fn draw_bg_pixel_grid(
}

/// Tiled heropattern background.
///
/// The tile is rasterized once at `cfg.scale`, using `heropattern_raster_size`
/// and a matching `resvg` render transform, then tiled 1:1 by the shader.
/// It used to be rasterized at 1x and magnified by the shader's own matrix
/// instead, which turned the vector source into hard nearest-neighbour
/// blocks above `scale: 1` and aliased it below `scale: 1`.
fn draw_bg_heropattern(
canvas: &Canvas,
cfg: &HeropatternConfig,
Expand All @@ -584,7 +590,6 @@ fn draw_bg_heropattern(
return;
}

// Build the SVG source with color/opacity substituted
let svg_content = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">{}</svg>"#,
def.width,
Expand All @@ -596,20 +601,23 @@ fn draw_bg_heropattern(
.replace("{{opacity}}", &cfg.opacity.to_string()),
);

// Render one tile via usvg/resvg
let opt = usvg::Options::default();
let Ok(tree) = usvg::Tree::from_data(svg_content.as_bytes(), &opt) else {
return;
};

let pw = def.width.ceil() as u32;
let ph = def.height.ceil() as u32;
let (pw, ph) = heropattern_raster_size(def.width, def.height, cfg.scale);
let Some(mut pixmap) = tiny_skia::Pixmap::new(pw, ph) else {
return;
};
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
let render_scale_x = pw as f32 / def.width;
let render_scale_y = ph as f32 / def.height;
resvg::render(
&tree,
tiny_skia::Transform::from_scale(render_scale_x, render_scale_y),
&mut pixmap.as_mut(),
);

// Convert to Skia image
let info = ImageInfo::new(
(pw as i32, ph as i32),
ColorType::RGBA8888,
Expand All @@ -625,16 +633,10 @@ fn draw_bg_heropattern(
return;
};

// Build a tiled shader from the tile image
let matrix = if cfg.scale != 1.0 {
Some(skia_safe::Matrix::scale((cfg.scale, cfg.scale)))
} else {
None
};
let Some(shader) = tile_image.to_shader(
(skia_safe::TileMode::Repeat, skia_safe::TileMode::Repeat),
skia_safe::SamplingOptions::default(),
matrix.as_ref(),
skia_safe::SamplingOptions::new(skia_safe::FilterMode::Linear, skia_safe::MipmapMode::None),
None,
) else {
return;
};
Expand All @@ -655,6 +657,21 @@ fn draw_bg_heropattern(
);
}

/// Pixel size to rasterize one heropattern tile at, so the vector source is
/// re-rendered crisp at `scale` instead of rasterized at the pattern's
/// native `(width, height)` and then magnified. Clamped to `MAX_TILE_PX`
/// per axis: `HeropatternConfig::scale` has no upper bound in the schema, so
/// an unclamped scale could ask for an arbitrarily large pixmap allocation.
/// A clamped tile still tiles seamlessly with itself — it just renders
/// smaller than an extreme `scale` asked for, which is the trade the "sane
/// maximum" this is named for is making.
fn heropattern_raster_size(width: f32, height: f32, scale: f32) -> (u32, u32) {
const MAX_TILE_PX: f32 = 4096.0;
let pw = (width * scale).ceil().clamp(1.0, MAX_TILE_PX) as u32;
let ph = (height * scale).ceil().clamp(1.0, MAX_TILE_PX) as u32;
(pw, ph)
}

/// Interpolate two AnimatedBackground structs. `t` goes from 0.0 (fully `a`) to 1.0 (fully `b`).
#[allow(dead_code)]
pub(super) fn interpolate_animated_bg(
Expand Down Expand Up @@ -1523,3 +1540,50 @@ mod heropattern_period_tests {
assert!(spacing_x >= 20.0);
}
}

#[cfg(test)]
mod heropattern_raster_tests {
//! The heropattern tile used to be rasterized at 1x (the
//! pattern's native width/height) and then magnified by the shader's
//! own matrix with nearest-neighbour sampling — blocky above `scale: 1`,
//! aliased below it. `heropattern_raster_size` must honour `scale`
//! directly in the raster resolution instead.

use super::*;

#[test]
fn raster_size_scales_with_cfg_scale_not_pinned_to_1x() {
let (pw, ph) = heropattern_raster_size(32.0, 64.0, 4.0);
assert_eq!(
(pw, ph),
(128, 256),
"the pixmap must be sized for the scaled tile, not the pattern's native 32x64"
);
}

#[test]
fn raster_size_matches_the_pattern_exactly_at_scale_1() {
assert_eq!(heropattern_raster_size(32.0, 64.0, 1.0), (32, 64));
}

#[test]
fn raster_size_is_clamped_for_an_unbounded_scale() {
let (pw, ph) = heropattern_raster_size(32.0, 64.0, 100_000.0);
assert!(
pw <= 4096 && ph <= 4096,
"an extreme scale must not attempt an unbounded pixmap allocation, got {pw}x{ph}"
);
}

#[test]
fn draw_bg_heropattern_does_not_panic_at_an_extreme_scale() {
let mut surface = skia_safe::surfaces::raster_n32_premul((64, 64)).expect("surface");
let cfg = HeropatternConfig {
pattern: "aztec".to_string(),
color: "#FFFFFF".to_string(),
opacity: 0.1,
scale: 100_000.0,
};
draw_bg_heropattern(surface.canvas(), &cfg, 0.0, 64.0, 64.0);
}
}
33 changes: 14 additions & 19 deletions crates/rustmotion/src/engine/render/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,41 +553,36 @@ fn render_with_new_pipeline_iter<'a, I>(
/// Paint a decorative leaf (e.g. Particle) over the full viewport without
/// going through taffy. Resolves animations and dispatches to
/// `Painter::paint_content` directly with a viewport-sized `BoxLayout`.
///
/// Visibility and effects go through the same `PaintWindow::contains` and
/// `effective_effects` the ordinary `paint_tree` dispatch uses (see
/// `box_builder::effective_effects`'s doc comment), rather than re-deriving
/// both by hand — a component whose `timeline`/`style.transition` state or
/// exact `end_at` boundary only worked in one of the two dispatch paths used
/// to be invisible to tests written against either one alone.
fn paint_decorative_fullscreen(
canvas: &Canvas,
child: &ChildComponent,
viewport_w: f32,
viewport_h: f32,
ctx: &RenderContext,
) {
use rustmotion_components::box_builder::effective_effects;
use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties};
use rustmotion_core::engine::box_tree::PaintWindow;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::traits::PaintCtx;

let time = ctx.time.seconds();
if let Some(timed) = child.component.as_timed() {
let (start_at, end_at) = timed.timing();
if let Some(s) = start_at {
if time < s {
return;
}
}
if let Some(e) = end_at {
if time > e {
return;
}
let (start, end) = timed.timing();
if !(PaintWindow { start, end }).contains(time) {
return;
}
}

let props = match child.component.as_animatable() {
Some(a) => {
let effects = a.animation_effects();
if effects.is_empty() {
AnimatedProperties::default()
} else {
resolve_props_for_effects(effects, time, ctx.scene_duration)
}
}
let props = match effective_effects(&child.component, 0.0) {
Some(effects) => resolve_props_for_effects(&effects, time, ctx.scene_duration),
None => AnimatedProperties::default(),
};
if props.opacity <= 0.0 {
Expand Down
139 changes: 132 additions & 7 deletions crates/rustmotion/tests/audit_ws_e.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,143 @@
//! Regression tests — audit round chantier/audit-2026-09, workstream E
//! (animated backgrounds & the scene render path).
//! Regression tests — workstream E (animated backgrounds & the scene render
//! path).
//!
//! Two of the four findings are bugs in fully private
//! rendering internals (`background.rs`'s `tile_spacing`/`compute_scroll_offset`/
//! Some of the covered defects are bugs in fully private rendering
//! internals (`background.rs`'s `tile_spacing`/`compute_scroll_offset`/
//! `draw_bg_heropattern`) that this crate never exposes past its `pub`
//! surface — an external integration test crate like this one cannot name
//! them. Their regression tests live as `#[cfg(test)]` modules inside
//! `crates/rustmotion/src/engine/render/background.rs` itself, following
//! that file's own pre-existing convention (`scroll_offset_wrap_tests`,
//! `pixel_grid_tests`, `grid_lines_tests`, `halo_opacity_tests`) for testing
//! renderer-private logic directly. This file carries the findings that are
//! renderer-private logic directly. This file carries the defects that are
//! genuinely reachable through the crate's public API.
//!
//! One section per finding: the paint path, then colour templating.

use rustmotion::encode::video::{build_frame_tasks, render_frame_task, FrameTask};
use rustmotion::loader::load_scenario_from_source;

/// Render a single `FrameTask::WorldFrame` at `frame_in_view`, decoded from
/// `scenario_json`. Panics (with a message naming the missing frame) if no
/// such world frame exists in the built schedule — a test bug, not a
/// render-time failure, should fail loudly here.
fn render_world_frame(scenario_json: &serde_json::Value, frame_in_view: u32) -> Vec<u8> {
let scenario = load_scenario_from_source(None, Some(&scenario_json.to_string()))
.expect("scenario is schema-valid");
let tasks = build_frame_tasks(&scenario);
let task = tasks
.iter()
.find(
|t| matches!(t, FrameTask::WorldFrame { frame_in_view: f, .. } if *f == frame_in_view),
)
.unwrap_or_else(|| panic!("no WorldFrame task at frame_in_view={frame_in_view}"));
render_frame_task(&scenario.video, &scenario, task).expect("frame renders")
}

/// Count pixels that read as the particle's pure-green marker colour
/// (`#00FF00`) against the scenario's plain black background — a stand-in
/// for "is the decorative child visible in this frame" that doesn't depend
/// on knowing any particle's exact on-screen position.
fn green_pixel_count(buf: &[u8]) -> usize {
buf.chunks_exact(4)
.filter(|px| px[1] > 100 && px[0] < 80 && px[2] < 80)
.count()
}

mod decorative_dispatch_parity {
//! `paint_decorative_fullscreen` (the world-view-only path that paints
//! decorative children like `particle` without going through the box
//! tree) used to re-derive visibility and effects by hand instead of
//! calling `PaintWindow::contains` / `box_builder::effective_effects`
//! like every other paint path does. Two independent symptoms: an
//! inclusive `end_at` (visible one frame too long) and dropped
//! `timeline` animation effects.

use super::*;

const FPS: u32 = 10;

fn world_scenario(particle_extra: serde_json::Value) -> serde_json::Value {
let mut particle = serde_json::json!({
"type": "particle",
"particle_type": "snow",
"count": 30,
"colors": ["#00FF00"],
"size_range": {"min": 6, "max": 6},
"speed": 0.0,
});
particle
.as_object_mut()
.unwrap()
.extend(particle_extra.as_object().unwrap().clone());

serde_json::json!({
"video": {"width": 64, "height": 64, "fps": FPS, "background": "#000000"},
"composition": [
{"type": "world", "scenes": [
{"duration": 2.0, "children": [particle]}
]}
]
})
}

/// `end_at` is a half-open window: the child must already be gone
/// exactly at `end_at`, not still visible for one extra frame past it.
#[test]
fn end_at_is_a_half_open_window_not_inclusive() {
let end_at = 0.5;
let scenario = world_scenario(serde_json::json!({ "end_at": end_at }));
let frame_just_before_end_at = (end_at * FPS as f64) as u32 - 1;
let frame_at_end_at = (end_at * FPS as f64) as u32;

let before = render_world_frame(&scenario, frame_just_before_end_at);
assert!(
green_pixel_count(&before) > 0,
"the particle must still be visible just before its end_at"
);

let at_boundary = render_world_frame(&scenario, frame_at_end_at);
assert_eq!(
green_pixel_count(&at_boundary),
0,
"end_at is a half-open window ([start, end)): the particle must already be gone \
exactly at end_at, not one extra frame later"
);
}

/// A `timeline` step's `animation` entries must be folded into the
/// resolved props like every other paint path does, not silently
/// dropped because only `style.animation` was read.
#[test]
fn timeline_animation_effects_are_not_silently_dropped() {
let fade_out_at = 0.5;
let scenario = world_scenario(serde_json::json!({
"timeline": [{
"at": 0.0,
"animation": [{
"name": "keyframes",
"keyframes": [{
"property": "opacity",
"keyframes": [
{"time": 0.0, "value": 1.0},
{"time": fade_out_at, "value": 0.0}
]
}]
}]
}]
}));

let early = render_world_frame(&scenario, 0);
assert!(
green_pixel_count(&early) > 0,
"the particle should be visible at t=0, before the timeline fade-out completes"
);

let frame_well_past_fade_out = (fade_out_at * FPS as f64) as u32 * 3;
let late = render_world_frame(&scenario, frame_well_past_fade_out);
assert_eq!(
green_pixel_count(&late),
0,
"a timeline step's animation effects must apply to a decorative child, not be \
silently dropped"
);
}
}
Loading