diff --git a/Cargo.lock b/Cargo.lock index 42c6776..8841dd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "approx" version = "0.5.1" @@ -895,7 +907,7 @@ dependencies = [ "bevy_world_serialization", "fixedbitset", "gltf", - "itertools", + "itertools 0.14.0", "percent-encoding", "serde", "serde_json", @@ -1128,7 +1140,7 @@ dependencies = [ "bevy_reflect", "derive_more", "glam", - "itertools", + "itertools 0.14.0", "libm", "rand", "rand_distr", @@ -1389,7 +1401,7 @@ dependencies = [ "glam", "image", "indexmap", - "itertools", + "itertools 0.14.0", "js-sys", "naga", "nonmax", @@ -1964,6 +1976,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.65" @@ -1994,6 +2012,58 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -2211,6 +2281,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -2226,6 +2332,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -2921,6 +3046,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -3145,6 +3272,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3447,11 +3594,12 @@ dependencies = [ name = "motiongfx" version = "0.3.0" dependencies = [ - "bevy_ecs", "bevy_math", - "bevy_platform", + "criterion", "field_path", + "hashbrown 0.17.1", "nonempty", + "typarena", ] [[package]] @@ -4008,6 +4156,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "orbclient" version = "0.3.55" @@ -4186,6 +4340,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.18.1" @@ -4386,6 +4568,26 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.39.2" @@ -4739,6 +4941,12 @@ dependencies = [ "serde", ] +[[package]] +name = "sparse_map" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea8f483b9159a7efadd9a82915dc40fdc9cfef7985651bbb83a8db20b12bf629" + [[package]] name = "spin" version = "0.10.0" @@ -4952,6 +5160,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -5093,6 +5311,17 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +[[package]] +name = "typarena" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc412794331fd10ad620a88fea59d0b7da84e08eb652a350458b61e4c7b57167" +dependencies = [ + "hashbrown 0.16.1", + "indexmap", + "sparse_map", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 8992cc3..74ff71b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,9 @@ bevy_color = { version = "0.19", default-features = false } # other field_path = "0.4.1" +typarena = { version = "0.1.0", features = ["send", "sync"] } nonempty = { version = "0.12", default-features = false } +hashbrown = "0.17" peniko = { version = "0.6", default-features = false } vello = "0.9.0" winit = "0.30" diff --git a/crates/bevy_motiongfx/src/manager.rs b/crates/bevy_motiongfx/src/manager.rs index 50f8e91..7c817e7 100644 --- a/crates/bevy_motiongfx/src/manager.rs +++ b/crates/bevy_motiongfx/src/manager.rs @@ -37,7 +37,7 @@ impl Plugin for MotionGfxManagerPlugin { /// /// Panics if the [`Timeline`] component is sampling itself. fn sample_timelines(world: &mut World) { - world.try_resource_scope::( + world.resource_scope::( |world, mut motiongfx| { motiongfx.load_pending_timelines(world); motiongfx.sample_timelines(world); diff --git a/crates/motiongfx/Cargo.toml b/crates/motiongfx/Cargo.toml index 51cea18..e218daf 100644 --- a/crates/motiongfx/Cargo.toml +++ b/crates/motiongfx/Cargo.toml @@ -12,17 +12,24 @@ repository.workspace = true [dependencies] # bevy -bevy_ecs = { workspace = true } -bevy_platform = { workspace = true } bevy_math = { workspace = true } # other field_path = { workspace = true } +hashbrown = { workspace = true } nonempty = { workspace = true } +typarena = { workspace = true } + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "action_storage" +harness = false [lints] workspace = true [features] default = ["std"] -std = ["bevy_ecs/std", "bevy_platform/std", "bevy_math/std"] +std = ["bevy_math/std"] diff --git a/crates/motiongfx/benches/action_storage.rs b/crates/motiongfx/benches/action_storage.rs new file mode 100644 index 0000000..33d74f4 --- /dev/null +++ b/crates/motiongfx/benches/action_storage.rs @@ -0,0 +1,415 @@ +//! Storage-layer benchmark for motiongfx. +//! +//! This file uses only the public `motiongfx` API, which is identical +//! across the ECS (`main`) and typarena (`nixon/typarena`) branches. +//! That lets the *same* benchmark run on both worktrees so the timing +//! delta is attributable purely to the action-storage backend. +//! +//! Three phases of the animation loop are measured separately: +//! * `build` — construct + compile a timeline (insertion into store) +//! * `bake` — recompute all segments from subject start values +//! * `scrub` — full-playback queue+sample sweep (the per-frame path) +//! +//! Each phase runs twice: a single-type scene (`f32`, one pipeline) and +//! a `mixed_*` scene (four distinct value types, four pipelines) that +//! stresses per-type dispatch and — in bevy ECS — archetype width. +//! +//! Run (see the module docs / your notes for baseline comparison): +//! cargo bench -p motiongfx + +use criterion::{ + BenchmarkId, Criterion, Throughput, criterion_group, + criterion_main, +}; +use std::hint::black_box; + +use motiongfx::prelude::*; + +/// Subject identifier. Distinct ids give distinct `ActionKey`s. +#[derive( + Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, +)] +struct Id(u64); + +#[derive(Debug, Default, Clone, Copy)] +#[allow(dead_code)] // `y` rounds out a realistic 2-field subject. +struct Point { + x: f32, + y: f32, +} + +/// Dense, index-addressable subject world so the benchmark measures +/// motiongfx internals rather than the host container's lookup cost. +struct SubjectWorld { + points: Vec, +} + +impl SubjectSource for SubjectWorld { + fn get_source(&self, id: Id) -> Option<&Point> { + self.points.get(id.0 as usize) + } + + fn apply_source( + &mut self, + id: Id, + f: impl FnOnce(&mut Point) -> R, + ) -> Option { + self.points.get_mut(id.0 as usize).map(f) + } +} + +fn linear_f32(a: &f32, b: &f32, t: f32) -> f32 { + *a + (*b - *a) * t +} + +fn make_world(n: u64) -> SubjectWorld { + SubjectWorld { + points: vec![Point::default(); n as usize], + } +} + +/// Build a timeline with `n` subjects, each animating `Point::x` over a +/// 1s clip. All `n` sequences run concurrently in a single track. +fn build_timeline( + registry: &mut Registry, + n: u64, +) -> Timeline { + let mut builder = registry.create_builder::(); + + let fragments = (0..n) + .map(|i| { + builder + .act_builder(Id(i), path!(::x), |x| x + 72.0) + .with_interp(linear_f32) + .play(1.0) + }) + .collect::>(); + + builder.add_tracks(fragments.ord_all().compile()); + builder.compile() +} + +fn bench_build(c: &mut Criterion) { + let mut group = c.benchmark_group("build"); + for &n in &[100u64, 1_000, 10_000] { + group.throughput(Throughput::Elements(n)); + group.bench_with_input( + BenchmarkId::from_parameter(n), + &n, + |b, &n| { + b.iter(|| { + // Fresh registry each iter: measures store + // population from scratch. + let mut registry = Registry::new(); + let timeline = + build_timeline(&mut registry, black_box(n)); + black_box((registry, timeline)); + }); + }, + ); + } + group.finish(); +} + +fn bench_bake(c: &mut Criterion) { + let mut group = c.benchmark_group("bake"); + for &n in &[100u64, 1_000, 10_000] { + group.throughput(Throughput::Elements(n)); + group.bench_with_input( + BenchmarkId::from_parameter(n), + &n, + |b, &n| { + let mut registry = Registry::new(); + let mut timeline = build_timeline(&mut registry, n); + let world = make_world(n); + // bake is idempotent, so re-baking the same timeline + // measures the segment-recompute cost each iteration. + b.iter(|| { + timeline.bake_actions( + black_box(®istry), + black_box(&world), + ); + }); + }, + ); + } + group.finish(); +} + +fn bench_scrub(c: &mut Criterion) { + const FRAMES: u32 = 60; + let mut group = c.benchmark_group("scrub"); + for &n in &[100u64, 1_000, 10_000] { + // Elements = subjects sampled per frame, across all frames. + group.throughput(Throughput::Elements(n * FRAMES as u64)); + group.bench_with_input( + BenchmarkId::from_parameter(n), + &n, + |b, &n| { + let mut registry = Registry::new(); + let mut timeline = build_timeline(&mut registry, n); + let mut world = make_world(n); + timeline.bake_actions(®istry, &world); + let duration = timeline.curr_track().duration(); + + // One full playback sweep of FRAMES steps, then back to + // the start so successive iterations are identical. + b.iter(|| { + for frame in 0..=FRAMES { + let t = + duration * frame as f32 / FRAMES as f32; + timeline.set_target_time(black_box(t)); + timeline.queue_actions(); + timeline.sample_queued_actions( + ®istry, &mut world, + ); + } + // Rewind to t=0 for the next iteration. + timeline.set_target_time(0.0); + timeline.queue_actions(); + timeline + .sample_queued_actions(®istry, &mut world); + black_box(&world); + }); + }, + ); + } + group.finish(); +} + +// --------------------------------------------------------------------- +// Mixed-type scene. +// +// The benches above use a single value type (`f32`), i.e. one pipeline +// and one column set — the best case for ECS (a single archetype). Real +// scenes animate many value types. `Widget` carries four distinct types +// animated concurrently, producing four pipelines / four heterogeneous +// column sets in typarena and a four-wide archetype in bevy ECS, which +// exercises the per-type dispatch and column-resolution paths. +// --------------------------------------------------------------------- + +#[derive(Debug, Default, Clone, Copy)] +struct V2 { + x: f32, + y: f32, +} +#[derive(Debug, Default, Clone, Copy)] +struct V3 { + x: f32, + y: f32, + z: f32, +} +#[derive(Debug, Default, Clone, Copy)] +struct V4 { + x: f32, + y: f32, + z: f32, + w: f32, +} + +/// A subject with four distinct field types, each its own pipeline. +#[derive(Debug, Default, Clone, Copy)] +struct Widget { + s: f32, + p: V2, + r: V3, + c: V4, +} + +struct MixedWorld { + widgets: Vec, +} + +impl SubjectSource for MixedWorld { + fn get_source(&self, id: Id) -> Option<&Widget> { + self.widgets.get(id.0 as usize) + } + + fn apply_source( + &mut self, + id: Id, + f: impl FnOnce(&mut Widget) -> R, + ) -> Option { + self.widgets.get_mut(id.0 as usize).map(f) + } +} + +fn lerp2(a: &V2, b: &V2, t: f32) -> V2 { + V2 { + x: linear_f32(&a.x, &b.x, t), + y: linear_f32(&a.y, &b.y, t), + } +} +fn lerp3(a: &V3, b: &V3, t: f32) -> V3 { + V3 { + x: linear_f32(&a.x, &b.x, t), + y: linear_f32(&a.y, &b.y, t), + z: linear_f32(&a.z, &b.z, t), + } +} +fn lerp4(a: &V4, b: &V4, t: f32) -> V4 { + V4 { + x: linear_f32(&a.x, &b.x, t), + y: linear_f32(&a.y, &b.y, t), + z: linear_f32(&a.z, &b.z, t), + w: linear_f32(&a.w, &b.w, t), + } +} + +fn make_mixed_world(n: u64) -> MixedWorld { + MixedWorld { + widgets: vec![Widget::default(); n as usize], + } +} + +/// Build a timeline with `n` widgets, each animating all four typed +/// fields over a 1s clip — `4 * n` actions across four pipelines. +fn build_mixed( + registry: &mut Registry, + n: u64, +) -> Timeline { + let mut builder = registry.create_builder::(); + + let fragments = (0..n) + .flat_map(|i| { + [ + builder + .act_builder(Id(i), path!(::s), |v| { + v + 1.0 + }) + .with_interp(linear_f32) + .play(1.0), + builder + .act_builder( + Id(i), + path!(::p), + |v: &V2| V2 { + x: v.x + 1.0, + y: v.y + 1.0, + }, + ) + .with_interp(lerp2) + .play(1.0), + builder + .act_builder( + Id(i), + path!(::r), + |v: &V3| V3 { + x: v.x + 1.0, + y: v.y + 1.0, + z: v.z + 1.0, + }, + ) + .with_interp(lerp3) + .play(1.0), + builder + .act_builder( + Id(i), + path!(::c), + |v: &V4| V4 { + x: v.x + 1.0, + y: v.y + 1.0, + z: v.z + 1.0, + w: v.w + 1.0, + }, + ) + .with_interp(lerp4) + .play(1.0), + ] + }) + .collect::>(); + + builder.add_tracks(fragments.ord_all().compile()); + builder.compile() +} + +fn bench_mixed_build(c: &mut Criterion) { + let mut group = c.benchmark_group("mixed_build"); + for &n in &[100u64, 1_000, 10_000] { + group.throughput(Throughput::Elements(n * 4)); + group.bench_with_input( + BenchmarkId::from_parameter(n), + &n, + |b, &n| { + b.iter(|| { + let mut registry = Registry::new(); + let timeline = + build_mixed(&mut registry, black_box(n)); + black_box((registry, timeline)); + }); + }, + ); + } + group.finish(); +} + +fn bench_mixed_bake(c: &mut Criterion) { + let mut group = c.benchmark_group("mixed_bake"); + for &n in &[100u64, 1_000, 10_000] { + group.throughput(Throughput::Elements(n * 4)); + group.bench_with_input( + BenchmarkId::from_parameter(n), + &n, + |b, &n| { + let mut registry = Registry::new(); + let mut timeline = build_mixed(&mut registry, n); + let world = make_mixed_world(n); + b.iter(|| { + timeline.bake_actions( + black_box(®istry), + black_box(&world), + ); + }); + }, + ); + } + group.finish(); +} + +fn bench_mixed_scrub(c: &mut Criterion) { + const FRAMES: u32 = 60; + let mut group = c.benchmark_group("mixed_scrub"); + for &n in &[100u64, 1_000, 10_000] { + group.throughput(Throughput::Elements(n * 4 * FRAMES as u64)); + group.bench_with_input( + BenchmarkId::from_parameter(n), + &n, + |b, &n| { + let mut registry = Registry::new(); + let mut timeline = build_mixed(&mut registry, n); + let mut world = make_mixed_world(n); + timeline.bake_actions(®istry, &world); + let duration = timeline.curr_track().duration(); + + b.iter(|| { + for frame in 0..=FRAMES { + let t = + duration * frame as f32 / FRAMES as f32; + timeline.set_target_time(black_box(t)); + timeline.queue_actions(); + timeline.sample_queued_actions( + ®istry, &mut world, + ); + } + timeline.set_target_time(0.0); + timeline.queue_actions(); + timeline + .sample_queued_actions(®istry, &mut world); + black_box(&world); + }); + }, + ); + } + group.finish(); +} + +criterion_group!( + benches, + bench_build, + bench_bake, + bench_scrub, + bench_mixed_build, + bench_mixed_bake, + bench_mixed_scrub, +); +criterion_main!(benches); diff --git a/crates/motiongfx/src/action.rs b/crates/motiongfx/src/action.rs index c596ac4..6e045fd 100644 --- a/crates/motiongfx/src/action.rs +++ b/crates/motiongfx/src/action.rs @@ -1,111 +1,19 @@ use core::any::TypeId; -use core::marker::PhantomData; use alloc::boxed::Box; -use alloc::vec::Vec; -use bevy_ecs::lifecycle::HookContext; -use bevy_ecs::prelude::*; -use bevy_ecs::world::DeferredWorld; -use bevy_platform::collections::HashMap; use field_path::field::UntypedField; use crate::ThreadSafe; use crate::subject::SubjectId; -use crate::track::TrackFragment; -/// A type-erased unique Id in the [`IdRegistry`]. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, -)] -pub struct UId(u64); - -/// A type-erased [`UId`] map and generator for each unique -/// [`SubjectId`]s. It also performs book keeping for all id instances -/// and remove them when there is none left. -#[derive(Resource)] -pub struct IdRegistry { - /// Maps `SubjectId`s to [`UId`]s . - uid_map: HashMap, - /// Maps [`UId`]s to `SubjectId`s. - id_map: HashMap, - /// The number of instances using the same [`UId`]. - instance_counts: HashMap, - /// The next [`UId`], incremented on every new [`UId`] created. - next_uid: UId, -} - -impl IdRegistry { - pub fn new() -> Self { - Self { - uid_map: HashMap::new(), - id_map: HashMap::new(), - instance_counts: HashMap::new(), - next_uid: UId(0), - } - } - - /// Registers the [`SubjectId`] with an intial instance count of 1 - /// if it doesn't exist yet, otherwise, increase the existing - /// instance count. - /// - /// Returns the [`UId`] of the associated [`SubjectId`]. - pub fn register_instance(&mut self, id: I) -> UId { - let uid = *self.uid_map.entry(id).or_insert_with(|| { - self.next_uid.0 += 1; - self.id_map.insert(self.next_uid, id); - self.instance_counts.insert(self.next_uid, 1); - self.next_uid - }); - - // SAFETY: `uid_counts` is added for every new UId! - *self.instance_counts.get_mut(&uid).unwrap() += 1; - - uid - } - - /// Reduce the instance count of a [`SubjectId`] associated with - /// the provided [`UId`]. When the instance count reaches 0, the - /// entire registry will be erased. - /// - /// Returns `true` if the instance is being successfully removed, - /// `false` if the registry doesn't exist in the first place. - pub fn remove_instance(&mut self, uid: &UId) -> bool { - let Some(count) = self.instance_counts.get_mut(uid) else { - return false; - }; +mod id_registry; +mod table; - *count -= 1; - - // Remove the underlying data when it's the last instance. - if *count == 0 { - let id = self.id_map.get(uid).unwrap(); - self.uid_map.remove(id); - self.id_map.remove(uid); - self.instance_counts.remove(uid); - } - - true - } - - /// Checks if the registry is empty. - pub fn is_empty(&self) -> bool { - self.uid_map.is_empty() - } - - pub fn get_uid(&self, id: &I) -> Option<&UId> { - self.uid_map.get(id) - } - - pub fn get_id(&self, uid: &UId) -> Option<&I> { - self.id_map.get(uid) - } -} - -impl Default for IdRegistry { - fn default() -> Self { - Self::new() - } -} +pub use id_registry::{IdRegistry, UId}; +pub use table::{ + ActionBuilder, ActionId, ActionMarker, ActionTable, + InterpActionBuilder, +}; #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, @@ -146,18 +54,14 @@ impl UntypedSubjectId { /// Key that uniquely identifies a sequence of non-overlapping /// actions. +/// +/// Treated as immutable by convention: `track.rs` stores this as a +/// `HashMap` key, so it must never be mutated in place after +/// insertion (previously enforced at compile time by +/// `#[component(immutable)]`; now just a documented invariant). #[derive( - Component, - Debug, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, )] -#[component(immutable)] pub struct ActionKey { /// The subject Id of the action. subject_id: UntypedSubjectId, @@ -182,276 +86,13 @@ impl ActionKey { } } -#[derive(Component, Debug, Clone, Copy)] -#[component(immutable, on_remove = on_remove_id_type::)] -pub struct IdType(PhantomData); - -impl IdType { - pub fn new() -> Self { - Self(PhantomData) - } -} - -impl Default for IdType { - fn default() -> Self { - Self::new() - } -} - -/// Remove an instance of the target [`SubjectId`] when an action -/// entity is being despawned. -fn on_remove_id_type( - mut world: DeferredWorld<'_>, - ctx: HookContext, -) { - let uid = world - .entity(ctx.entity) - .get::() - .expect("Should have an `ActionKey`!") - .subject_id - .uid; - - let mut registry = world.resource_mut::>(); - registry.remove_instance(&uid); - - if registry.is_empty() { - world.commands().remove_resource::>(); - } -} - -#[derive(Default)] -pub struct ActionWorld { - world: World, -} - -impl ActionWorld { - pub fn new() -> Self { - let mut world = World::new(); - // EaseStorage is optional, so it needs to be registered - // manually for the sample query to be valid. - world.register_component::(); - - Self { world } - } - - pub fn add( - &mut self, - target: I, - field: impl Into, - action: impl Action, - ) -> ActionBuilder<'_, T> - where - I: SubjectId, - T: ThreadSafe, - { - let field = field.into(); - - let uid = self - .world - .get_resource_or_insert_with(|| IdRegistry::new()) - .register_instance(target); - - let key = - ActionKey::new(UntypedSubjectId::new::(uid), field); - let world = self.world.spawn(( - key, - IdType::::new(), - ActionStorage::new(action), - )); - - ActionBuilder { - world, - key, - _phantom: PhantomData, - } - } - - pub fn remove(&mut self, id: ActionId) -> Option { - let entity = id.entity(); - - let key = *self - .world - .get_entity(entity) - .ok()? - .get::() - .expect("All actions should have an `ActionKey`!"); - - self.world.despawn(id.entity()); - // Apply associated commands from hooks and observer when - // despawning. - self.world.flush(); - - Some(key) - } - - pub fn get_action( - &self, - id: ActionId, - ) -> Option<&impl Action> { - self.world - .get::>(id.entity()) - .map(|a| &a.action) - } - - pub fn get_id(&self, uid: &UId) -> Option<&I> { - self.world.get_resource::>()?.get_id(uid) - } -} - -impl ActionWorld { - /// Returns a immutable reference to the underlying world. - pub(crate) fn world(&self) -> &World { - &self.world - } - - /// Create an [`ActionCommand`] from an [`ActionId`]. - /// - /// # Panics - /// - /// Panics if the action does not exists in the world. - /// - /// In general, this should not be an issue as this is only used - /// internally within the crate. - pub(crate) fn edit_action( - &mut self, - id: ActionId, - ) -> ActionCommand<'_> { - ActionCommand { - world: self.world.entity_mut(id.entity()), - } - } - - /// Remove [`SampleMode`] component from all marked actions. - pub(crate) fn clear_all_marks(&mut self) { - let Some(mut q) = self - .world - .try_query_filtered::>() - else { - return; - }; - - let entities = q.iter(&self.world).collect::>(); - for entity in entities { - self.world.entity_mut(entity).remove::(); - } - } -} - -pub(crate) struct ActionCommand<'w> { - world: EntityWorldMut<'w>, -} - -impl ActionCommand<'_> { - pub(crate) fn mark( - &mut self, - sample_mode: SampleMode, - ) -> &mut Self { - self.world.insert(sample_mode); - self - } - - pub(crate) fn clear_mark(&mut self) -> &mut Self { - self.world.remove::(); - self - } - - /// Add or replace the segment of the action. - pub(crate) fn set_segment( - &mut self, - segment: Segment, - ) -> &mut Self - where - T: ThreadSafe, - { - self.world.insert(segment); - self - } -} - -pub struct ActionBuilder<'w, T> { - world: EntityWorldMut<'w>, - key: ActionKey, - _phantom: PhantomData, -} - -/// A builder struct to insert an interpolation method for the action -/// before compiling into an [`InterpActionBuilder`]. -impl ActionBuilder<'_, T> { - /// Get the [`ActionId`] of the containing action. - pub fn id(&self) -> ActionId { - ActionId::new(self.world.id()) - } -} - -impl<'w, T> ActionBuilder<'w, T> -where - T: 'static, -{ - /// Set the interpolation method of the action. - pub fn with_interp( - mut self, - interp: InterpFn, - ) -> InterpActionBuilder<'w, T> { - self.world.insert(InterpStorage(interp)); - InterpActionBuilder { inner: self } - } -} - -/// An action builder that has interpolation added. This builder -/// exposes more customizations for the action and allows it to be -/// compiled into a [`TrackFragment`]. -pub struct InterpActionBuilder<'w, T> { - inner: ActionBuilder<'w, T>, -} - -impl InterpActionBuilder<'_, T> { - /// Set the easing method of the action. - pub fn with_ease(mut self, ease: EaseFn) -> Self { - self.inner.world.insert(EaseStorage(ease)); - self - } - - /// Get the [`ActionId`] of the containing action. - pub fn id(&self) -> ActionId { - self.inner.id() - } - - /// Confirms the configuration of the action and creates a - /// [`TrackFragment`]. - pub fn play(self, duration: f32) -> TrackFragment { - TrackFragment::single( - self.inner.key, - ActionClip::new(self.id(), duration), - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ActionId(Entity); - -impl ActionId { - pub const PLACEHOLDER: Self = ActionId(Entity::PLACEHOLDER); - - #[inline(always)] - pub(crate) fn new(entity: Entity) -> Self { - Self(entity) - } - - #[inline(always)] - pub(crate) fn entity(&self) -> Entity { - self.0 - } -} - /// An action trait which consists of a function for getting /// the target value based on an intial value. pub trait Action: ThreadSafe + Fn(&T) -> T {} impl Action for U where U: ThreadSafe + Fn(&T) -> T {} -/// A storage component for an [`Action`]. -#[derive(Component)] -#[component(immutable)] +/// A storage value for an [`Action`]. pub struct ActionStorage { pub action: Box>, } @@ -467,23 +108,21 @@ impl ActionStorage { /// Function for interpolating a type based on a [`f32`] time. pub type InterpFn = fn(start: &T, end: &T, t: f32) -> T; -/// A storage component for a custom [`InterpFn`]. +/// A storage value for a custom [`InterpFn`]. /// /// This can be optionally inserted alongside [`ActionStorage`] /// to customize the action. -#[derive(Component, Debug, Clone, Copy)] -#[component(immutable)] +#[derive(Debug, Clone, Copy)] pub struct InterpStorage(pub InterpFn); /// Easing function on a [`f32`] time. pub type EaseFn = fn(t: f32) -> f32; -/// A storage component for a custom [`EaseFn`]. +/// A storage value for a custom [`EaseFn`]. /// /// This can be optionally inserted alongside [`ActionStorage`] /// to customize the action. -#[derive(Component, Debug, Clone, Copy)] -#[component(immutable)] +#[derive(Debug, Clone, Copy)] pub struct EaseStorage(pub EaseFn); #[derive(Debug, Clone, Copy, PartialEq)] @@ -508,8 +147,6 @@ impl ActionClip { } } -#[derive(Component)] -#[component(immutable)] pub struct Segment { /// The starting value. pub start: T, @@ -524,8 +161,7 @@ impl Segment { } /// Determines how a [`Segment`] should be sampled. -#[derive(Component, Debug, Clone, Copy)] -#[component(storage = "SparseSet", immutable)] +#[derive(Debug, Clone, Copy)] pub enum SampleMode { Start, End, diff --git a/crates/motiongfx/src/action/id_registry.rs b/crates/motiongfx/src/action/id_registry.rs new file mode 100644 index 0000000..5c32db2 --- /dev/null +++ b/crates/motiongfx/src/action/id_registry.rs @@ -0,0 +1,222 @@ +use core::any::TypeId; + +use hashbrown::HashMap; + +use crate::resources::Resources; +use crate::subject::SubjectId; + +/// A type-erased unique Id in the [`IdRegistry`]. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, +)] +pub struct UId(pub(super) u64); + +/// A type-erased [`UId`] map and generator for each unique +/// [`SubjectId`]s. It also performs book keeping for all id instances +/// and remove them when there is none left. +pub struct IdRegistry { + /// Maps `SubjectId`s to [`UId`]s . + uid_map: HashMap, + /// Maps [`UId`]s to `SubjectId`s. + id_map: HashMap, + /// The number of instances using the same [`UId`]. + instance_counts: HashMap, + /// The next [`UId`], incremented on every new [`UId`] created. + next_uid: UId, +} + +impl IdRegistry { + pub fn new() -> Self { + Self { + uid_map: HashMap::new(), + id_map: HashMap::new(), + instance_counts: HashMap::new(), + next_uid: UId(0), + } + } + + /// Registers the [`SubjectId`] with an intial instance count of 1 + /// if it doesn't exist yet, otherwise, increase the existing + /// instance count. + /// + /// Returns the [`UId`] of the associated [`SubjectId`]. + pub fn register_instance(&mut self, id: I) -> UId { + let uid = *self.uid_map.entry(id).or_insert_with(|| { + self.next_uid.0 += 1; + self.id_map.insert(self.next_uid, id); + // Starts at 0: the unconditional `+= 1` below always + // runs next, bringing a freshly registered id to the + // documented initial count of 1. + self.instance_counts.insert(self.next_uid, 0); + self.next_uid + }); + + // SAFETY: `uid_counts` is added for every new UId! + *self.instance_counts.get_mut(&uid).unwrap() += 1; + + uid + } + + /// Reduce the instance count of a [`SubjectId`] associated with + /// the provided [`UId`]. When the instance count reaches 0, the + /// entire registry will be erased. + /// + /// Returns `true` if the instance is being successfully removed, + /// `false` if the registry doesn't exist in the first place. + pub fn remove_instance(&mut self, uid: &UId) -> bool { + let Some(count) = self.instance_counts.get_mut(uid) else { + return false; + }; + + *count -= 1; + + // Remove the underlying data when it's the last instance. + if *count == 0 { + let id = self.id_map.get(uid).unwrap(); + self.uid_map.remove(id); + self.id_map.remove(uid); + self.instance_counts.remove(uid); + } + + true + } + + /// Checks if the registry is empty. + pub fn is_empty(&self) -> bool { + self.uid_map.is_empty() + } + + pub fn get_uid(&self, id: &I) -> Option<&UId> { + self.uid_map.get(id) + } + + pub fn get_id(&self, uid: &UId) -> Option<&I> { + self.id_map.get(uid) + } +} + +impl Default for IdRegistry { + fn default() -> Self { + Self::new() + } +} + +/// A type-erased cleanup function, run when an action is removed to +/// decrement the spawning [`SubjectId`]'s [`IdRegistry`] entry. +/// +/// `I` is baked in via monomorphization at insertion time (see +/// [`cleanup_fn`]); the caller only needs to supply the removed +/// action's [`UId`] when calling it back. +pub(crate) type Cleanup = fn(&mut Resources, UId); + +/// One [`Cleanup`] per distinct [`SubjectId`] type `I` used across +/// the whole [`ActionTable`](crate::action::ActionTable), keyed by +/// `I`'s [`TypeId`]. Stored as a [`Resources`] entry itself, right +/// alongside each `I`'s [`IdRegistry`], rather than duplicated once +/// per action — the function is identical for every action sharing +/// the same `I`. +pub(crate) type CleanupRegistry = HashMap; + +pub(crate) fn cleanup_fn( + resources: &mut Resources, + uid: UId, +) { + let Some(registry) = resources.get_mut::>() else { + return; + }; + registry.remove_instance(&uid); + + if registry.is_empty() { + resources.remove::>(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_registry_is_empty() { + let registry = IdRegistry::::new(); + assert!(registry.is_empty()); + } + + #[test] + fn register_instance_resolves_both_ways() { + let mut registry = IdRegistry::new(); + let uid = registry.register_instance(7u32); + + assert_eq!(registry.get_uid(&7u32), Some(&uid)); + assert_eq!(registry.get_id(&uid), Some(&7u32)); + assert!(!registry.is_empty()); + } + + #[test] + fn register_instance_same_subject_returns_same_uid() { + let mut registry = IdRegistry::new(); + let uid1 = registry.register_instance(7u32); + let uid2 = registry.register_instance(7u32); + + assert_eq!(uid1, uid2); + } + + #[test] + fn register_instance_distinct_subjects_get_distinct_uids() { + let mut registry = IdRegistry::new(); + let uid1 = registry.register_instance(1u32); + let uid2 = registry.register_instance(2u32); + + assert_ne!(uid1, uid2); + } + + #[test] + fn remove_instance_erases_entry_after_single_registration() { + let mut registry = IdRegistry::new(); + let uid = registry.register_instance(7u32); + + assert!(registry.remove_instance(&uid)); + assert!(registry.is_empty()); + assert_eq!(registry.get_id(&uid), None); + assert_eq!(registry.get_uid(&7u32), None); + } + + #[test] + fn remove_instance_ref_counts_multiple_registrations() { + let mut registry = IdRegistry::new(); + let uid1 = registry.register_instance(7u32); + let uid2 = registry.register_instance(7u32); + assert_eq!(uid1, uid2); + + // One instance removed, one remains: entry must survive. + assert!(registry.remove_instance(&uid1)); + assert!(!registry.is_empty()); + assert_eq!(registry.get_id(&uid1), Some(&7u32)); + + // Last instance removed: entry is erased. + assert!(registry.remove_instance(&uid1)); + assert!(registry.is_empty()); + assert_eq!(registry.get_id(&uid1), None); + } + + #[test] + fn remove_instance_unknown_uid_returns_false() { + let mut registry = IdRegistry::::new(); + let uid = registry.register_instance(1u32); + registry.remove_instance(&uid); + + // Already removed: a second removal finds nothing. + assert!(!registry.remove_instance(&uid)); + } + + #[test] + fn registrations_of_different_subjects_are_independent() { + let mut registry = IdRegistry::new(); + let uid1 = registry.register_instance(1u32); + let uid2 = registry.register_instance(2u32); + + assert!(registry.remove_instance(&uid1)); + assert!(!registry.is_empty()); + assert_eq!(registry.get_id(&uid1), None); + assert_eq!(registry.get_id(&uid2), Some(&2u32)); + } +} diff --git a/crates/motiongfx/src/action/table.rs b/crates/motiongfx/src/action/table.rs new file mode 100644 index 0000000..a987caa --- /dev/null +++ b/crates/motiongfx/src/action/table.rs @@ -0,0 +1,302 @@ +use core::any::TypeId; +use core::marker::PhantomData; + +use field_path::field::UntypedField; +use typarena::ColumnId; +use typarena::id::{GenId, IdGenerator}; +use typarena::type_table::TypeTable; + +use super::id_registry::{ + CleanupRegistry, IdRegistry, UId, cleanup_fn, +}; +use super::{ + Action, ActionClip, ActionKey, ActionStorage, EaseFn, + EaseStorage, InterpFn, InterpStorage, Segment, UntypedSubjectId, +}; +use crate::ThreadSafe; +use crate::resources::Resources; +use crate::subject::SubjectId; +use crate::track::TrackFragment; + +/// Phantom marker distinguishing [`ActionId`]'s [`GenId`] domain from +/// any other id domain. +pub struct ActionMarker; + +/// The generational id backing [`ActionId`] and every column key in +/// an [`ActionTable`]'s [`TypeTable`]. +pub type ActionId = GenId; + +/// Heterogeneous storage for every action spawned via +/// [`Self::add`], keyed by [`ActionId`]. +pub struct ActionTable { + table: TypeTable, + id_gen: IdGenerator, + resources: Resources, + key_col: ColumnId, + ease_col: ColumnId, +} + +impl ActionTable { + pub fn new() -> Self { + let mut table = TypeTable::new(); + let key_col = table.ensure_column::(); + let ease_col = table.ensure_column::(); + + Self { + table, + id_gen: IdGenerator::new(), + resources: Resources::default(), + key_col, + ease_col, + } + } + + pub fn add( + &mut self, + target: I, + field: impl Into, + action: impl Action, + ) -> ActionBuilder<'_, T> + where + I: SubjectId, + T: ThreadSafe, + { + let field = field.into(); + + let uid = self + .resources + .get_or_insert_with(IdRegistry::new) + .register_instance(target); + self.resources + .get_or_insert_with(CleanupRegistry::new) + .insert(TypeId::of::(), cleanup_fn::); + + let key = + ActionKey::new(UntypedSubjectId::new::(uid), field); + let id = self.id_gen.new_id(); + self.table.insert_by_column(id, key, self.key_col); + self.table.insert(id, ActionStorage::new(action)); + + ActionBuilder { + table: &mut self.table, + id, + key, + ease_col: self.ease_col, + _phantom: PhantomData, + } + } + + pub fn remove(&mut self, id: ActionId) -> Option { + let key = *self + .table + .get_by_column::(self.key_col, &id)?; + + let cleanup = self + .resources + .get::() + .and_then(|fns| fns.get(&key.subject_id().type_id())) + .copied(); + if let Some(cleanup) = cleanup { + cleanup(&mut self.resources, key.subject_id().uid()); + } + self.table.remove_row(&id); + self.id_gen.recycle(id); + + Some(key) + } + + pub fn get_action( + &self, + id: &ActionId, + ) -> Option<&impl Action> { + self.table.get::>(id).map(|a| &a.action) + } + + pub fn get_id(&self, uid: &UId) -> Option<&I> { + self.resources.get::>()?.get_id(uid) + } +} + +impl ActionTable { + /// Returns a immutable reference to the underlying storage. + pub(crate) fn table(&self) -> &TypeTable { + &self.table + } + + /// Returns the [`ActionKey`] for `id`, using the cached column. + pub(crate) fn key(&self, id: &ActionId) -> Option<&ActionKey> { + self.table.get_by_column::(self.key_col, id) + } + + /// Returns the [`EaseStorage`] for `id`, if any, using the + /// cached column. + pub(crate) fn ease(&self, id: &ActionId) -> Option<&EaseStorage> { + self.table.get_by_column::(self.ease_col, id) + } + + /// Get-or-create the [`Segment`] column, cached by bake. + pub(crate) fn ensure_segment_column( + &mut self, + ) -> ColumnId { + self.table.ensure_column::>() + } + + /// The [`ActionStorage`] column, if any `T` action exists. + pub(crate) fn action_column( + &self, + ) -> Option { + self.table.type_column::>() + } + + /// Read an action through a cached [`ActionStorage`] column. + pub(crate) fn get_action_by_column( + &self, + col: ColumnId, + id: &ActionId, + ) -> Option<&impl Action> { + self.table + .get_by_column::>(col, id) + .map(|a| &a.action) + } + + /// Write a segment through a cached [`Segment`] column. + pub(crate) fn set_segment_by_column( + &mut self, + id: ActionId, + segment: Segment, + col: ColumnId, + ) { + self.table.insert_by_column(id, segment, col); + } +} + +impl Default for ActionTable { + fn default() -> Self { + Self::new() + } +} + +pub struct ActionBuilder<'w, T> { + table: &'w mut TypeTable, + id: ActionId, + key: ActionKey, + ease_col: ColumnId, + _phantom: PhantomData, +} + +/// A builder struct to insert an interpolation method for the action +/// before compiling into an [`InterpActionBuilder`]. +impl ActionBuilder<'_, T> { + /// Get the [`ActionId`] of the containing action. + pub fn id(&self) -> ActionId { + self.id + } +} + +impl<'w, T> ActionBuilder<'w, T> +where + T: 'static, +{ + /// Set the interpolation method of the action. + pub fn with_interp( + self, + interp: InterpFn, + ) -> InterpActionBuilder<'w, T> { + self.table.insert(self.id, InterpStorage(interp)); + InterpActionBuilder { inner: self } + } +} + +/// An action builder that has interpolation added. This builder +/// exposes more customizations for the action and allows it to be +/// compiled into a [`TrackFragment`]. +pub struct InterpActionBuilder<'w, T> { + inner: ActionBuilder<'w, T>, +} + +impl InterpActionBuilder<'_, T> { + /// Set the easing method of the action. + pub fn with_ease(self, ease: EaseFn) -> Self { + self.inner.table.insert_by_column( + self.inner.id, + EaseStorage(ease), + self.inner.ease_col, + ); + self + } + + /// Get the [`ActionId`] of the containing action. + pub fn id(&self) -> ActionId { + self.inner.id() + } + + /// Confirms the configuration of the action and creates a + /// [`TrackFragment`]. + pub fn play(self, duration: f32) -> TrackFragment { + TrackFragment::single( + self.inner.key, + ActionClip::new(self.id(), duration), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn field() -> UntypedField { + UntypedField::placeholder_with_path("x") + } + + #[test] + fn add_then_remove_round_trip() { + let mut world = ActionTable::new(); + let id = world.add(1u32, field(), |x: &f32| x + 1.0).id(); + + assert!(world.get_action::(&id).is_some()); + + let key = world.remove(id); + assert!(key.is_some()); + assert!(world.get_action::(&id).is_none()); + } + + #[test] + fn id_recycling_produces_distinct_ids() { + let mut world = ActionTable::new(); + + let id1 = world.add(1u32, field(), |x: &f32| *x).id(); + world.remove(id1); + + let id2 = world.add(2u32, field(), |x: &f32| *x).id(); + + // The recycled slot is reused, but the bumped generation + // must keep the stale id from resolving to the new row. + assert_ne!(id1, id2); + assert!(world.get_action::(&id1).is_none()); + assert!(world.get_action::(&id2).is_some()); + } + + #[test] + fn remove_decrements_registry_ref_count() { + let mut world = ActionTable::new(); + + // Two actions against the same `SubjectId` share one + // `IdRegistry` entry (multi-instance ref-counting). + let builder1 = world.add(7u32, field(), |x: &f32| *x); + let uid = builder1.key.subject_id().uid(); + let id1 = builder1.id(); + + let id2 = world.add(7u32, field(), |x: &f32| *x).id(); + + assert_eq!(world.get_id::(&uid), Some(&7u32)); + + world.remove(id1); + // One instance remains, so the registry entry survives. + assert_eq!(world.get_id::(&uid), Some(&7u32)); + + world.remove(id2); + // Last instance gone: the `Cleanup` closure must have + // dropped the (now-empty) `IdRegistry` entry. + assert_eq!(world.get_id::(&uid), None); + } +} diff --git a/crates/motiongfx/src/lib.rs b/crates/motiongfx/src/lib.rs index 56e19aa..709c74e 100644 --- a/crates/motiongfx/src/lib.rs +++ b/crates/motiongfx/src/lib.rs @@ -8,6 +8,7 @@ pub mod ease; pub mod interpolation; pub mod pipeline; pub mod registry; +mod resources; pub mod sequence; pub mod subject; pub mod timeline; diff --git a/crates/motiongfx/src/pipeline.rs b/crates/motiongfx/src/pipeline.rs index bc3eb1c..8d77ef1 100644 --- a/crates/motiongfx/src/pipeline.rs +++ b/crates/motiongfx/src/pipeline.rs @@ -7,7 +7,7 @@ use func_pointers::{BakeFnPtr, SampleFnPtr}; use crate::ThreadSafe; use crate::action::{ - ActionClip, ActionKey, ActionWorld, EaseStorage, InterpStorage, + ActionClip, ActionId, ActionKey, ActionTable, InterpStorage, SampleMode, Segment, }; use crate::pipeline::func_pointers::{BakeFn, SampleFn}; @@ -173,7 +173,7 @@ impl PipelineUntyped { pub struct BakeCtx<'a, W> { pub world: &'a W, pub track: &'a Track, - pub action_world: &'a mut ActionWorld, + pub action_table: &'a mut ActionTable, pub accessor_registry: &'a AccessorRegistry, } @@ -184,6 +184,14 @@ where S: 'static, T: Clone + ThreadSafe, { + // Resolve the per-`T` columns once so the clip loop doesn't + // re-hash the `TypeId` on every access. No `T` action, no bake. + let Some(action_col) = ctx.action_table.action_column::() + else { + return; + }; + let segment_col = ctx.action_table.ensure_segment_column::(); + for (key, span) in ctx.track.sequences_spans() { let Some(accessor) = ctx.accessor_registry.get::(key.field()) @@ -192,7 +200,7 @@ where }; let Some(&id) = - ctx.action_world.get_id(&key.subject_id().uid()) + ctx.action_table.get_id(&key.subject_id().uid()) else { continue; }; @@ -204,7 +212,9 @@ where let mut start = accessor.get_ref(source).clone(); for ActionClip { id, .. } in ctx.track.clips(*span) { - let Some(action) = ctx.action_world.get_action::(*id) + let Some(action) = ctx + .action_table + .get_action_by_column::(action_col, id) else { continue; }; @@ -212,7 +222,11 @@ where let end = action(&start); let segment = Segment::new(start.clone(), end.clone()); - ctx.action_world.edit_action(*id).set_segment(segment); + ctx.action_table.set_segment_by_column( + *id, + segment, + segment_col, + ); start = end; } @@ -221,8 +235,11 @@ where pub struct SampleCtx<'a, W> { pub world: &'a mut W, - pub action_world: &'a ActionWorld, + pub action_table: &'a ActionTable, pub accessor_registry: &'a AccessorRegistry, + /// The queued actions for this pipeline, each with its + /// [`SampleMode`] resolved at queue time. + pub samples: &'a [(ActionId, SampleMode)], } pub fn sample(ctx: SampleCtx) @@ -232,27 +249,39 @@ where S: 'static, T: Clone + ThreadSafe, { - let Some(mut q) = ctx.action_world.world().try_query::<( - &ActionKey, - &SampleMode, - &Segment, - &InterpStorage, - Option<&EaseStorage>, - )>() else { + let table = ctx.action_table.table(); + let Some(segment_col) = table.type_column::>() else { + return; + }; + let Some(interp_col) = table.type_column::>() + else { return; }; - for (key, sample_mode, segment, interp, ease) in - q.iter(ctx.action_world.world()) - { + for &(id, sample_mode) in ctx.samples { + let Some(segment) = + table.get_by_column::>(segment_col, &id) + else { + continue; + }; + let Some(interp) = + table.get_by_column::>(interp_col, &id) + else { + continue; + }; + + let Some(key) = ctx.action_table.key(&id) else { + continue; + }; + let ease = ctx.action_table.ease(&id); let Some(accessor) = ctx.accessor_registry.get::(key.field()) else { continue; }; - let Some(&id) = - ctx.action_world.get_id(&key.subject_id().uid()) + let Some(&sid) = + ctx.action_table.get_id(&key.subject_id().uid()) else { continue; }; @@ -262,15 +291,15 @@ where SampleMode::End => segment.end.clone(), SampleMode::Interp(t) => { let t = match ease { - Some(ease) => ease.0(*t), - None => *t, + Some(ease) => ease.0(t), + None => t, }; interp.0(&segment.start, &segment.end, t) } }; - ctx.world.apply_source(id, |source| { + ctx.world.apply_source(sid, |source| { *accessor.get_mut(source) = target; }); } @@ -291,8 +320,125 @@ impl Range { #[cfg(test)] mod tests { + use crate::interpolation::Interpolation; + use super::*; + struct MockWorld(f32); + + impl SubjectSource for MockWorld { + fn get_source(&self, _id: u32) -> Option<&f32> { + Some(&self.0) + } + + fn apply_source( + &mut self, + _id: u32, + f: impl FnOnce(&mut f32) -> R, + ) -> Option { + Some(f(&mut self.0)) + } + } + + fn sample_mock( + action_table: &ActionTable, + accessor_registry: &AccessorRegistry, + world: &mut MockWorld, + samples: &[(ActionId, SampleMode)], + ) { + sample::(SampleCtx { + world, + action_table, + accessor_registry, + samples, + }); + } + + /// Exercises the multi-column probe in `sample`: `ActionKey`, + /// `Segment` and `InterpStorage` are read per queued action, + /// with the `SampleMode` supplied by the queue. + #[test] + fn sample_join_reads_all_required_columns() { + let field_acc = crate::path!(); + let field = field_acc.field.untyped(); + + let mut accessor_registry = AccessorRegistry::new(); + accessor_registry.register(field_acc); + + let mut action_table = ActionTable::new(); + let id = action_table + .add(0u32, field, |x: &f32| *x + 10.0) + .with_interp(>::interp) + .id(); + let seg_col = action_table.ensure_segment_column::(); + action_table.set_segment_by_column( + id, + Segment::new(0.0f32, 10.0f32), + seg_col, + ); + + let mut world = MockWorld(0.0); + + sample_mock( + &action_table, + &accessor_registry, + &mut world, + &[(id, SampleMode::Start)], + ); + assert_eq!(world.0, 0.0); + + sample_mock( + &action_table, + &accessor_registry, + &mut world, + &[(id, SampleMode::End)], + ); + assert_eq!(world.0, 10.0); + + sample_mock( + &action_table, + &accessor_registry, + &mut world, + &[(id, SampleMode::Interp(0.5))], + ); + assert!((world.0 - 5.0).abs() < f32::EPSILON); + } + + /// The optional `EaseStorage` column must be probed too: a + /// present column should reshape `t` before interpolating. + #[test] + fn sample_join_applies_custom_ease() { + let field_acc = crate::path!(); + let field = field_acc.field.untyped(); + + let mut accessor_registry = AccessorRegistry::new(); + accessor_registry.register(field_acc); + + let mut action_table = ActionTable::new(); + let id = action_table + .add(0u32, field, |x: &f32| *x + 10.0) + .with_interp(>::interp) + .with_ease(crate::ease::quad::ease_in) + .id(); + let seg_col = action_table.ensure_segment_column::(); + action_table.set_segment_by_column( + id, + Segment::new(0.0f32, 10.0f32), + seg_col, + ); + let mut world = MockWorld(0.0); + sample_mock( + &action_table, + &accessor_registry, + &mut world, + &[(id, SampleMode::Interp(0.5))], + ); + + // quad::ease_in(0.5) == 0.25, so the eased target is 2.5, + // not the unmodified-t value of 5.0. + assert!((world.0 - 2.5).abs() < f32::EPSILON); + } + #[test] fn range_overlap_behavior() { let a = Range { diff --git a/crates/motiongfx/src/registry.rs b/crates/motiongfx/src/registry.rs index ad1dea7..d283594 100644 --- a/crates/motiongfx/src/registry.rs +++ b/crates/motiongfx/src/registry.rs @@ -1,9 +1,9 @@ use core::any::TypeId; -use bevy_platform::collections::HashMap; use field_path::accessor::{Accessor, UntypedAccessor}; use field_path::field::UntypedField; use field_path::field_accessor::FieldAccessor; +use hashbrown::HashMap; use crate::ThreadSafe; use crate::pipeline::{ diff --git a/crates/motiongfx/src/resources.rs b/crates/motiongfx/src/resources.rs new file mode 100644 index 0000000..d2b810f --- /dev/null +++ b/crates/motiongfx/src/resources.rs @@ -0,0 +1,43 @@ +use alloc::boxed::Box; +use core::any::{Any, TypeId}; + +use hashbrown::HashMap; + +/// A type-keyed singleton map, playing the role of a `bevy_ecs` +/// `Resource` store without depending on `bevy_ecs`. +#[derive(Default)] +pub(crate) struct Resources { + map: HashMap>, +} + +impl Resources { + pub(crate) fn get(&self) -> Option<&T> { + self.map.get(&TypeId::of::())?.downcast_ref() + } + + pub(crate) fn get_mut( + &mut self, + ) -> Option<&mut T> { + self.map.get_mut(&TypeId::of::())?.downcast_mut() + } + + pub(crate) fn get_or_insert_with( + &mut self, + f: impl FnOnce() -> T, + ) -> &mut T { + self.map + .entry(TypeId::of::()) + .or_insert_with(|| Box::new(f())) + .downcast_mut() + .expect( + "TypeId key always matches the boxed value's type", + ) + } + + pub(crate) fn remove( + &mut self, + ) -> Option { + let boxed = self.map.remove(&TypeId::of::())?; + boxed.downcast::().ok().map(|value| *value) + } +} diff --git a/crates/motiongfx/src/timeline.rs b/crates/motiongfx/src/timeline.rs index 611d30e..59f84f2 100644 --- a/crates/motiongfx/src/timeline.rs +++ b/crates/motiongfx/src/timeline.rs @@ -3,12 +3,12 @@ use core::marker::PhantomData; use alloc::boxed::Box; use alloc::vec::Vec; -use bevy_platform::collections::HashMap; use field_path::field_accessor::FieldAccessor; +use hashbrown::HashMap; use crate::ThreadSafe; use crate::action::{ - Action, ActionBuilder, ActionId, ActionKey, ActionWorld, + Action, ActionBuilder, ActionId, ActionKey, ActionTable, InterpActionBuilder, SampleMode, }; use crate::interpolation::Interpolation; @@ -19,7 +19,7 @@ use crate::track::Track; use crate::world::SubjectSource; pub struct Timeline { - action_world: ActionWorld, + action_table: ActionTable, pipeline_counts: Box<[(PipelineKey, u32)]>, /// Track length is guaranteed to be at least 1 by construction. /// See [`TimelineBuilder::compile()`]. @@ -29,6 +29,11 @@ pub struct Timeline { /// This cache will be cleared everytime [`Timeline::queue_actions`] /// is called. queue_cache: QueueCache, + /// Queued actions grouped by pipeline, each carrying its resolved + /// [`SampleMode`]. Rebuilt from `queue_cache` every + /// [`Timeline::queue_actions`] so sampling touches only the marked + /// actions of each type, with no per-action column lookup. + sample_queue: HashMap>, /// The current time of the current track. curr_time: f32, /// The target time of the target track. @@ -53,7 +58,7 @@ impl Timeline { BakeCtx { world: subject_world, track, - action_world: &mut self.action_world, + action_table: &mut self.action_table, accessor_registry: ®istry.accessor, }, ); @@ -121,12 +126,8 @@ impl Timeline { self.queue_cache.cache( *key, clip.id, - &mut self.action_world, + sample_mode, ); - - self.action_world - .edit_action(clip.id) - .mark(sample_mode); } } @@ -184,12 +185,8 @@ impl Timeline { self.queue_cache.cache( *key, clip.id, - &mut self.action_world, + SampleMode::Interp(t), ); - - self.action_world - .edit_action(clip.id) - .mark(SampleMode::Interp(t)); } // `target_time` is out of bounds. Err(index) => { @@ -205,27 +202,34 @@ impl Timeline { continue; } + // Target time before the sequence -> Start, + // otherwise it is past `index - 1` -> End (the + // saturating sub above handles the indexing). + let sample_mode = if index == 0 { + SampleMode::Start + } else { + SampleMode::End + }; + self.queue_cache.cache( *key, clip.id, - &mut self.action_world, + sample_mode, ); - let mut action_cmd = - self.action_world.edit_action(clip.id); - - if index == 0 { - // Target time is before the entire sequence. - action_cmd.mark(SampleMode::Start); - } else { - // Target time is after `index - 1`. - // Indexing taken care by the saturating sub - // above. - action_cmd.mark(SampleMode::End); - } } } } + // Group the deduped queue by pipeline so each typed sampler + // iterates only its own actions, with the `SampleMode` in hand. + for (key, &(id, sample_mode)) in self.queue_cache.iter() { + let pkey = PipelineKey::from_action_key::(*key); + self.sample_queue + .entry(pkey) + .or_default() + .push((id, sample_mode)); + } + self.curr_time = self.target_time; } @@ -234,13 +238,17 @@ impl Timeline { registry: &Registry, subject_world: &mut W, ) { - for key in self.pipeline_counts.iter().map(|(key, _)| key) { + for (key, samples) in self.sample_queue.iter() { + if samples.is_empty() { + continue; + } let ok = registry.pipeline.sample( key, SampleCtx { world: subject_world, - action_world: &self.action_world, + action_table: &self.action_table, accessor_registry: ®istry.accessor, + samples, }, ); debug_assert!(ok, "pipeline not found for key {key:?}"); @@ -249,7 +257,10 @@ impl Timeline { fn reset_queues(&mut self) { self.queue_cache.clear(); - self.action_world.clear_all_marks(); + // Retain the per-pipeline `Vec` capacities across frames. + for samples in self.sample_queue.values_mut() { + samples.clear(); + } } } @@ -359,7 +370,7 @@ impl Timeline { /// in an unordered manner. #[derive(Debug)] pub struct QueueCache { - cache: HashMap, + cache: HashMap, } impl QueueCache { @@ -375,7 +386,8 @@ impl QueueCache { pub fn iter( &self, - ) -> impl Iterator { + ) -> impl Iterator + { self.cache.iter() } @@ -383,8 +395,8 @@ impl QueueCache { self.cache.keys() } - pub fn iter_ids(&self) -> impl Iterator { - self.cache.values() + pub fn iter_ids(&self) -> impl Iterator + '_ { + self.cache.values().map(|(id, _)| *id) } /// Clear all the cached contents. @@ -392,17 +404,15 @@ impl QueueCache { self.cache.clear(); } - /// Cache an [`ActionKey`] while deduplicating the old cache if - /// it exists. + /// Cache an [`ActionKey`] with its [`SampleMode`], overwriting any + /// previous entry for the same key (dedup per field per subject). pub fn cache( &mut self, key: ActionKey, id: ActionId, - action_world: &mut ActionWorld, + sample_mode: SampleMode, ) { - if let Some(prev_id) = self.cache.insert(key, id) { - action_world.edit_action(prev_id).clear_mark(); - } + self.cache.insert(key, (id, sample_mode)); } } @@ -414,7 +424,7 @@ impl Default for QueueCache { pub struct TimelineBuilder<'a, W> { registry: &'a mut Registry, - action_world: ActionWorld, + action_table: ActionTable, pipeline_counts: HashMap, tracks: Vec, _marker: PhantomData W>, @@ -425,7 +435,7 @@ impl<'a, W: 'static> TimelineBuilder<'a, W> { pub fn new(registry: &'a mut Registry) -> Self { Self { registry, - action_world: ActionWorld::new(), + action_table: ActionTable::new(), pipeline_counts: HashMap::new(), tracks: Vec::new(), _marker: PhantomData, @@ -495,12 +505,12 @@ impl<'a, W: 'static> TimelineBuilder<'a, W> { } } - self.action_world.add(target, field, action) + self.action_table.add(target, field, action) } /// Remove an [`Action`]. pub fn unact(&mut self, id: ActionId) -> bool { - if let Some(key) = self.action_world.remove(id) { + if let Some(key) = self.action_table.remove(id) { let pipeline_key = PipelineKey::from_action_key::(key); let count = self @@ -547,13 +557,14 @@ impl<'a, W: 'static> TimelineBuilder<'a, W> { ); Timeline { - action_world: self.action_world, + action_table: self.action_table, pipeline_counts: self .pipeline_counts .into_iter() .collect(), tracks: self.tracks.into_boxed_slice(), queue_cache: QueueCache::new(), + sample_queue: HashMap::new(), curr_time: 0.0, target_time: 0.0, curr_index: 0, diff --git a/crates/motiongfx/src/track.rs b/crates/motiongfx/src/track.rs index ba29ccc..d671731 100644 --- a/crates/motiongfx/src/track.rs +++ b/crates/motiongfx/src/track.rs @@ -1,7 +1,7 @@ use alloc::boxed::Box; use alloc::vec::Vec; -use bevy_platform::collections::HashMap; use field_path::field::UntypedField; +use hashbrown::HashMap; use crate::action::{ActionClip, ActionKey}; use crate::sequence::Sequence; @@ -344,12 +344,15 @@ pub struct Span { #[cfg(test)] mod tests { - use bevy_ecs::entity::Entity; - use crate::action::{ActionId, IdRegistry, UntypedSubjectId}; use super::*; + #[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, + )] + struct DummyId(u32); + fn key(path: &'static str) -> ActionKey { ActionKey::new( UntypedSubjectId::PLACEHOLDER, @@ -366,8 +369,8 @@ mod tests { // Sequence with 0 duration to prevent overlaps. const DUMMY_SEQ: Sequence = Sequence::new(clip(0.0)); - let entity1 = Entity::from_raw_u32(1).unwrap(); - let entity2 = Entity::from_raw_u32(2).unwrap(); + let entity1 = DummyId(1); + let entity2 = DummyId(2); let field_u32_a = UntypedField::placeholder_with_path("a"); let field_u32_b = UntypedField::placeholder_with_path("b"); @@ -376,15 +379,15 @@ mod tests { let id2 = id_registry.register_instance(entity2); let k1 = ActionKey::new( - UntypedSubjectId::new::(id1), + UntypedSubjectId::new::(id1), field_u32_a, ); let k2 = ActionKey::new( - UntypedSubjectId::new::(id2), + UntypedSubjectId::new::(id2), field_u32_a, ); let k3 = ActionKey::new( - UntypedSubjectId::new::(id1), + UntypedSubjectId::new::(id1), field_u32_b, );