fix: preserve legacy CC particle compositing (#1898)

This commit is contained in:
skylar taylor-barrick
2026-10-23 09:01:10 -06:00
parent 9581b53c3f
commit 4983ba447b
5 changed files with 246 additions and 9 deletions
+35 -6
View File
@@ -1226,15 +1226,25 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
.collect();
list.sort_by(|a, b| b.0.total_cmp(&a.0));
let sprites: Vec<Sprite> = list.into_iter().map(|(_, s)| s).collect();
Some(particle_sprites(sprites, pr.e("particle/transferMode")))
Some(particle_sprites(sprites, pr.e("particle/transferMode"), pr.b("compositeWithOriginal")))
}
/// The CC particle systems' sprites with their Transfer Mode (Composite, Screen, Add, Black
/// Background).
fn particle_sprites(sprites: Vec<Sprite>, mode: u32) -> SpritePlan {
// The particles replace the layer (After Effects shows no trace of the source in any
// Transfer Mode, which sets how the particles meet each other).
SpritePlan { sprites, tints: vec![], acc: if mode == 2 { Acc::Add } else { Acc::Over }, post: Post::Replace }
fn particle_sprites(sprites: Vec<Sprite>, mode: u32, with_original: bool) -> SpritePlan {
// New instances replace the layer. Projects saved before v0.10.0 keep the old transfer
// modes over the input, including any earlier particle effects (#1898). This plan is
// shared by CPU and GPU rendering.
let post = if with_original {
Post::Combine(match mode {
0..=2 => mode,
3 => 5,
_ => 0,
})
} else {
Post::Replace
};
SpritePlan { sprites, tints: vec![], acc: if mode == 2 { Acc::Add } else { Acc::Over }, post }
}
// ------------------------------------------------------------------ CC Particle Systems II
@@ -1296,7 +1306,7 @@ fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * unit * s, q.v[1] * unit * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0)
})
.collect();
particle_sprites(sprites, pr.e("particle/transferMode"))
particle_sprites(sprites, pr.e("particle/transferMode"), pr.b("compositeWithOriginal"))
}
// ------------------------------------------------------------------ CC Mr. Mercury
@@ -1556,6 +1566,7 @@ pub fn specs() -> Vec<EffectSpec> {
p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)),
p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
p("compositeWithOriginal", "Composite With Original", Value::Bool(false), ParamUi::Checkbox),
],
particle_world,
),
@@ -1584,6 +1595,7 @@ pub fn specs() -> Vec<EffectSpec> {
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
p("compositeWithOriginal", "Composite With Original", Value::Bool(false), ParamUi::Checkbox),
],
particle_systems2,
),
@@ -2003,6 +2015,23 @@ mod tests {
}
}
#[test]
fn legacy_particle_compositing_keeps_the_original_transfer_modes() {
for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"] {
for (mode, combine_mode) in [(0, 0), (1, 1), (2, 2), (3, 5)] {
let base = ramp(48, 48);
let mut vals = vec![("birthRate", num(3.0)), ("particle/transferMode", Value::Enum(mode))];
let particles = run(id, &vals, base.clone(), 1.0);
vals.push(("compositeWithOriginal", Value::Bool(true)));
let legacy = run(id, &vals, base.clone(), 1.0);
assert_eq!(legacy, combine(&base, &particles, combine_mode), "{id}, transfer mode {mode}");
}
let base = grey(48, 48);
let vals = [("birthRate", num(0.0)), ("compositeWithOriginal", Value::Bool(true))];
assert_eq!(run(id, &vals, base.clone(), 1.0), base, "{id}: no particles leaves the source visible");
}
}
#[test]
fn mercury_blobs_and_seek() {
let v = vec![("producer", pt(24.0, 16.0)), ("radiusX", num(5.0)), ("radiusY", num(5.0)), ("birthRate", num(3.0))];
+14
View File
@@ -1188,6 +1188,13 @@ impl Session {
/// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema.
pub fn upgrade_effects(p: &mut Project) {
let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE;
// Before v0.10.0, the CC particle systems composited over their input. Applying the new
// replacement behavior to those files hides their artwork and earlier effects (#1898).
// A missing/unrecognised writer is not evidence of an old project.
let version = p.saved_by.split(['-', '+']).next().unwrap_or("");
let legacy_particles = version.split('.').count() == 3
&& version.split('.').all(|part| part.parse::<u64>().is_ok())
&& effectcraft_project::is_newer_version("0.10.0", version);
// Effect bounds per layer: (size, top-left in layer coordinates).
let mut bounds = std::collections::HashMap::new();
for (cid, c) in p.comps() {
@@ -1213,7 +1220,13 @@ pub fn upgrade_effects(p: &mut Project) {
let effectcraft_project::Node::Group(g) = n else { continue };
let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue };
if let Some(spec) = effectcraft_effects::find(effect) {
let composite = legacy_particles
&& matches!(spec.id, "ec.sim.ccparticlesystems2" | "ec.sim.ccparticleworld")
&& g.get("compositeWithOriginal").is_none();
effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size);
if composite && let Some(pr) = g.get_mut("compositeWithOriginal") {
pr.value = effectcraft_keyframe::Value::Bool(true);
}
if to_effect_space {
effectcraft_effects::migrate::to_effect_space(spec, g, size, d, &clone_d);
}
@@ -1223,6 +1236,7 @@ pub fn upgrade_effects(p: &mut Project) {
}
p.next_id = next;
p.schema = effectcraft_project::SCHEMA_VERSION;
p.saved_by = effectcraft_project::APP_VERSION.to_string();
}
#[cfg(test)]
@@ -0,0 +1,178 @@
//! Compatibility of particle effects in projects saved before v0.10.0 (#1898).
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Layer, LayerId, PropGroup};
use effectcraft_time::Tick;
use serde_json::json;
use effectcraft_engine::Session;
fn comp_with_two_solids() -> (Session, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 400, "height": 300})).unwrap()["layer"].as_u64().unwrap();
(s, a, b)
}
fn layer(s: &Session, id: u64) -> Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
fn apply(s: &mut Session, lid: u64, effect: &str) -> u64 {
s.execute("effect.apply", json!({"layers": [lid], "effect": effect})).unwrap()["effects"][0].as_u64().unwrap()
}
fn effect_group(s: &Session, lid: u64, uid: u64) -> PropGroup {
layer(s, lid).effects().unwrap().groups().find(|g| g.uid == uid).unwrap().clone()
}
fn animate_effect(s: &mut Session, lid: u64, uid: u64, path: &str, keys: &[(f64, f64)]) {
let prop = effect_group(s, lid, uid).prop(path).unwrap().uid;
for &(time, value) in keys {
s.execute("prop.addKey", json!({"layer": lid, "prop": prop, "time": time, "timeBase": "comp", "value": value})).unwrap();
}
}
/// #1898: v0.6.0 composited CC Particle Systems II over its input, including earlier
/// particle effects. Opening that project must keep both emitters and the precomp artwork.
#[test]
fn legacy_particle_compositing_survives_open_and_save() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 96, "height": 64, "frameRate": 30, "duration": 3})).unwrap();
let cid = s.active_comp_id().unwrap();
let lid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 96, "height": 64})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.precompose", json!({"layers": [lid], "name": "Button", "mode": "leave"})).unwrap();
for (x, color) in [(18.0, "#00ff00"), (78.0, "#0000ff")] {
let uid = apply(&mut s, lid, "ec.sim.ccparticlesystems2");
for (path, value) in [
("producer/position", json!([x, 20.0])),
("producer/radiusX", json!(0)),
("producer/radiusY", json!(0)),
("physics/velocity", json!(0)),
("physics/gravity", json!(0)),
("particle/particleType", json!(7)),
("particle/birthSize", json!(3)),
("particle/deathSize", json!(3)),
("particle/birthColor", json!(color)),
("particle/deathColor", json!(color)),
("particle/maxOpacity", json!(100)),
] {
let prop = effect_group(&s, lid, uid).prop(path).unwrap().uid;
s.execute("prop.set", json!({"layer": lid, "prop": prop, "value": value})).unwrap();
}
animate_effect(&mut s, lid, uid, "birthRate", &[(0.0, 1.5), (1.0, 2.5)]);
}
let original: Vec<_> = layer(&s, lid).effects().unwrap().groups().cloned().collect();
let file = legacy_particle_file(&s, Some("0.6.0"));
let dir = std::env::temp_dir().join(format!("ec-1898-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("old.ecproj").to_string_lossy().to_string();
std::fs::write(&path, file.to_string()).unwrap();
let mut opened = Session::default();
opened.execute("file.open", json!({"path": path})).unwrap();
let render = |s: &Session| s.render(cid, Tick::from_seconds_f64(0.5), Default::default());
let frame = render(&opened);
let visible = |img: &effectcraft_raster::Image| {
assert_eq!(img.get(48, 48), [1.0, 0.0, 0.0, 1.0], "the precomp artwork survives");
assert!(img.get(18, 20)[1] > 0.9, "the first particle effect survives the second");
assert!(img.get(78, 20)[2] > 0.9, "the second particle effect is visible");
};
visible(&frame);
for old in &original {
let now = effect_group(&opened, lid, old.uid);
assert_eq!(now.prop("birthRate"), old.prop("birthRate"), "animated values, times and ids are kept");
assert_eq!(now.prop("producer/position"), old.prop("producer/position"));
}
// Importing uses the same version-aware migration before remapping ids.
let mut imported = Session::default();
imported.execute("file.importProject", json!({"path": path})).unwrap();
let imported_cid = imported.project.items.values().find(|i| i.name == "Main").unwrap().id;
// Import remaps effect uids, hence particle seeds and sub-step birth offsets. The artwork
// and both emitters must survive, but antialiased edges need not be pixel-identical.
visible(&imported.render(imported_cid, Tick::from_seconds_f64(0.5), Default::default()));
let imported_fx: Vec<_> = imported.project.comp(imported_cid).unwrap().layers[0].effects().unwrap().groups().collect();
assert_eq!(imported_fx.len(), original.len());
for (now, old) in imported_fx.iter().zip(&original) {
assert_eq!(now.get("compositeWithOriginal").unwrap().value, KV::Bool(true));
assert_eq!(now.get("birthRate").unwrap().keys, old.get("birthRate").unwrap().keys);
}
opened.execute("file.save", json!({})).unwrap();
let mut reopened = Session::default();
reopened.execute("file.open", json!({"path": path})).unwrap();
assert!(render(&reopened) == frame, "saving as a current project keeps the migrated behavior");
assert_eq!(opened.project, reopened.project, "migration is idempotent");
std::fs::remove_dir_all(dir).unwrap();
}
fn legacy_particle_file(s: &Session, version: Option<&str>) -> serde_json::Value {
let mut file: serde_json::Value = serde_json::from_str(&s.project.to_file_json().unwrap()).unwrap();
match version {
Some(v) => file["savedBy"] = json!(v),
None => {
file.as_object_mut().unwrap().remove("savedBy");
}
}
// The compatibility control did not exist in v0.6.0.
fn remove_control(v: &mut serde_json::Value) {
match v {
serde_json::Value::Array(a) => {
a.retain(|n| n.get("match").and_then(serde_json::Value::as_str) != Some("compositeWithOriginal"));
for n in a {
remove_control(n);
}
}
serde_json::Value::Object(o) => {
for n in o.values_mut() {
remove_control(n);
}
}
_ => {}
}
}
remove_control(&mut file);
file
}
#[test]
fn legacy_particle_compositing_uses_writer_version_and_preserves_explicit_choices() {
let (mut s, lid, _) = comp_with_two_solids();
for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"] {
apply(&mut s, lid, id);
}
for (version, legacy) in [
(Some("0.6.0"), true),
(Some("0.9.2"), true),
(Some("0.9.2+build"), true),
(Some("0.10.0"), false),
(Some("0.16.0"), false),
(Some("1.0.0"), false),
(None, false),
(Some("unknown"), false),
(Some("0.6"), false),
] {
let file = legacy_particle_file(&s, version);
let mut p = effectcraft_project::Project::from_json(&file.to_string()).unwrap();
effectcraft_engine::upgrade_effects(&mut p);
let fx = p.comp_mut(s.active_comp_id().unwrap()).unwrap().layer_mut(LayerId(lid)).unwrap().props.sub_mut("effects").unwrap();
for n in &mut fx.children {
let effectcraft_project::Node::Group(g) = n else { continue };
assert_eq!(g.get("compositeWithOriginal").unwrap().value, KV::Bool(legacy), "{version:?}: {}", g.match_id);
g.get_mut("compositeWithOriginal").unwrap().value = KV::Bool(!legacy);
}
// An explicit value wins even when the file still names the old writer.
let mut explicit: serde_json::Value = serde_json::from_str(&p.to_file_json().unwrap()).unwrap();
explicit["savedBy"] = json!("0.6.0");
let mut again = effectcraft_project::Project::from_json(&explicit.to_string()).unwrap();
effectcraft_engine::upgrade_effects(&mut again);
assert_eq!(again, p);
}
for writer in [json!(null), json!(6), json!([]), json!({"version": "0.6.0"})] {
let mut file = legacy_particle_file(&s, None);
file["savedBy"] = writer;
let mut p = effectcraft_project::Project::from_json(&file.to_string()).unwrap();
effectcraft_engine::upgrade_effects(&mut p);
let fx = p.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap().effects().unwrap();
assert!(fx.groups().all(|g| g.get("compositeWithOriginal").unwrap().value == KV::Bool(false)));
}
}
+16
View File
@@ -39,6 +39,17 @@ pub const SCHEMA_EFFECT_SPACE: u32 = 2;
/// The EffectCraft version, written into project files as `savedBy`.
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
fn current_writer() -> String {
APP_VERSION.to_string()
}
fn read_writer<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
// Writer metadata was previously ignored by Project. Keep accepting absent or malformed
// metadata, but do not guess an old version and apply a compatibility migration to it.
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_str().map(str::to_string).unwrap_or_else(current_writer))
}
/// The EffectCraft version that wrote a project file (its `savedBy`), when it says.
pub fn saved_by(text: &str) -> Option<String> {
#[derive(Deserialize)]
@@ -1361,6 +1372,10 @@ impl Item {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Project {
pub schema: u32,
/// Source writer for version-specific migrations. File saves stamp the current writer;
/// internal snapshots omit it and already contain the migrated effect properties.
#[serde(rename = "savedBy", default = "current_writer", deserialize_with = "read_writer", skip_serializing)]
pub saved_by: String,
pub settings: ProjectSettings,
pub items: BTreeMap<ItemId, Item>,
pub next_id: u64,
@@ -1379,6 +1394,7 @@ impl Default for Project {
fn default() -> Self {
Project {
schema: SCHEMA_VERSION,
saved_by: current_writer(),
settings: ProjectSettings::default(),
items: BTreeMap::new(),
next_id: 1,
+3 -3
View File
@@ -3,7 +3,7 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
EffectCraft ships 307 effects with 3032 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
EffectCraft ships 307 effects with 3034 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
- **GPU**: 280 effects also run on the GPU compositor with identical results.
- **32**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping.
@@ -323,8 +323,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Drizzle | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented |
| CC Hair | `ec.sim.cchair` | 20 | GPU | 32 | Implemented |
| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented |
| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented |
| CC Particle World | `ec.sim.ccparticleworld` | 35 | GPU | 32 | Implemented |
| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 22 | GPU | 32 | Implemented |
| CC Particle World | `ec.sim.ccparticleworld` | 36 | GPU | 32 | Implemented |
| CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented |
| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented |
| CC Scatterize | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented |