Merge pull request #286 from storytold/fix/264-model-layers-cpu

Engine: adding a 3D model switches a Classic 3D comp to Advanced 3D (#264)
This commit is contained in:
BFlat
2026-10-08 20:59:53 +07:00
committed by GitHub
4 changed files with 125 additions and 12 deletions
+35 -11
View File
@@ -61,6 +61,9 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
sync_geometry_options(proj, cid);
Ok(())
})?;
if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) {
s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer");
}
Ok(json!({"renderer": renderer_id(r)}))
}
@@ -71,6 +74,25 @@ fn renderer_id(r: Renderer) -> &'static str {
}
}
/// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a
/// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether
/// the renderer changed.
fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let switched = c.renderer != Renderer::Advanced3D;
c.renderer = Renderer::Advanced3D;
sync_geometry_options(proj, cid);
Ok(switched)
}
/// Tell the user when [`use_advanced_3d`] changed the comp's renderer.
fn report_switch(s: &mut Session, cid: ItemId, switched: bool) {
if switched {
let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string();
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models"));
}
}
// ---------------------------------------------------------------- model layers
/// Set a property's value at comp time `t` (adds a key when it is animated).
@@ -121,7 +143,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
let index = index_p(p, "layer.newModel")?;
let position = position_p(p, "layer.newModel")?;
let id = s.edit("New 3D Model Layer", None, |proj, st| {
let (id, switched) = s.edit("New 3D Model Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
if let Some(slot) = l.props.children.iter_mut().find(|c| c.match_id() == "geometryOptions") {
@@ -131,9 +153,11 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
l.in_point = start;
l.out_point = comp.duration.max(start + fr.frame_duration());
place(&mut l, position);
insert_layer_at(proj, st, cid, l, index)
let id = insert_layer_at(proj, st, cid, l, index)?;
Ok((id, use_advanced_3d(proj, cid)?))
})?;
Ok(json!({"layer": id.0, "item": item.0}))
report_switch(s, cid, switched);
Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true}))
}
fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
@@ -187,7 +211,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| kind.label().to_string());
let t = super::time_p(s, p, Some(&comp));
let id = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
let (id, switched) = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Primitive { kind }, (comp.width, comp.height), None);
for k in ["width", "height", "depth", "radius", "tubeRadius", "segments", "rings"] {
if let Some(v) = f_p(p, k) {
@@ -199,11 +223,11 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
set_at(&mut l, "transform/position", t, KV::Vec3([g(0), g(1), g(2)]));
}
apply_material(&mut l, p, t, "layer.newPrimitive")?;
insert_layer(proj, st, cid, l)
let id = insert_layer(proj, st, cid, l)?;
Ok((id, use_advanced_3d(proj, cid)?))
})?;
// Primitives are drawn by the Advanced 3D renderer.
let advanced = s.project.comp(cid).is_some_and(|c| c.renderer == Renderer::Advanced3D);
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": advanced}))
report_switch(s, cid, switched);
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true}))
}
// ---------------------------------------------------------------- environment layer
@@ -352,7 +376,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Model Layer",
[],
None,
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}",
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_model
),
@@ -361,7 +385,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Primitive",
[],
None,
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_primitive
),
@@ -370,7 +394,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New 3D Primitive",
[],
None,
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_primitive
),
+36
View File
@@ -193,3 +193,39 @@ fn material_commands_and_serde() {
let back = Project::from_json(&json).unwrap();
assert_eq!(&back, &*s.project);
}
/// #264: only Advanced 3D draws models and primitives, and new comps are Classic 3D, so an
/// imported model was invisible. Adding one switches the comp to Advanced 3D (in the same undo
/// step, with a toast), as After Effects does.
#[test]
fn adding_models_switches_classic_comps_to_advanced_3d() {
let toasts = |s: &mut Session| -> Vec<String> {
s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect()
};
let mut s = session();
let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
let item = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap()["items"][0].as_u64().unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
toasts(&mut s);
let r = s.execute("layer.addItem", json!({"item": item})).unwrap();
assert_eq!(r["advanced3d"], true);
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
// As with Composition Settings: the text layer gets Geometry Options.
assert!(layer(&s, text).props.sub("geometryOptions").is_some());
assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")));
// A second model leaves the comp (and the user) alone.
s.execute("layer.addItem", json!({"item": item})).unwrap();
assert!(toasts(&mut s).is_empty());
s.undo();
s.undo();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
assert!(layer(&s, text).props.sub("geometryOptions").is_none());
// Primitives too.
s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
// Switching back to Classic 3D says why the model layers disappear.
toasts(&mut s);
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
assert!(toasts(&mut s).iter().any(|m| m.contains("Classic 3D doesn't draw")));
}
+52
View File
@@ -205,6 +205,58 @@ mod tests {
assert!(s.render(cid, s.time(), opts).data.iter().all(|p| p[3] == 0.0));
}
/// A binary glTF of a red cube 2 units wide (positions and indices only).
fn glb_cube() -> Vec<u8> {
let mut bin: Vec<u8> = (0..8u32).flat_map(|i| [i & 1, (i >> 1) & 1, (i >> 2) & 1].map(|b| b as f32 * 2.0 - 1.0)).flat_map(f32::to_le_bytes).collect();
let faces: [[u16; 4]; 6] = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
bin.extend(faces.iter().flat_map(|f| [f[0], f[1], f[2], f[0], f[2], f[3]]).flat_map(u16::to_le_bytes));
let gltf = json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1, "material": 0}]}],
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1, 0, 0, 1], "metallicFactor": 0, "roughnessFactor": 1}}],
"buffers": [{"byteLength": bin.len()}],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [-1, -1, -1], "max": [1, 1, 1]},
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"}
]
});
let mut text = gltf.to_string().into_bytes();
text.resize(text.len().div_ceil(4) * 4, b' ');
let chunk = |kind: &[u8; 4], data: &[u8]| [&(data.len() as u32).to_le_bytes()[..], kind, data].concat();
let body = [chunk(b"JSON", &text), chunk(b"BIN\0", &bin)].concat();
[&b"glTF"[..], &2u32.to_le_bytes(), &(12 + body.len() as u32).to_le_bytes(), &body].concat()
}
/// #264: a model imported into a new composition (Classic 3D by default) was invisible on
/// every renderer, because only Advanced 3D draws models. Adding it now switches the comp,
/// and the software renderer (no GPU, as on the reporter's Intel HD Graphics 5500) draws it.
#[test]
fn imported_glb_shows_in_a_new_composition() {
let path = std::env::temp_dir().join(format!("effectcraft-264-cube-{}.glb", std::process::id()));
std::fs::write(&path, glb_cube()).unwrap();
let mut s = super::session();
assert!(s.accel.is_none(), "renders in software");
let r = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
s.state.project_selection = vec![effectcraft_project::ItemId(r["items"][0].as_u64().unwrap())];
s.execute("file.newCompFromSelection", json!({})).unwrap();
let comp = s.active_comp().unwrap();
assert_eq!(comp.renderer, effectcraft_project::Renderer::Advanced3D);
let layer = comp.layers[0].id.0;
s.execute("prop.set", json!({"layer": layer, "path": "transform/rotationY", "value": 30})).unwrap();
s.execute("prop.set", json!({"layer": layer, "path": "transform/rotationX", "value": 20})).unwrap();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts { scale: 0.25, ..Default::default() });
let red = img.data.iter().filter(|p| p[3] > 0.99 && p[0] > 0.2 && p[1] < 0.05 && p[2] < 0.05).count();
let _ = std::fs::remove_file(&path);
// Half the comp height wide, seen at an angle: well over 5% of the frame.
assert!(red * 20 > img.data.len(), "red cube pixels: {red} of {}", img.data.len());
}
/// The web app's path: outputs go to a sink (downloads), never to the file system.
#[test]
fn render_queue_exports_to_a_sink() {
+2 -1
View File
@@ -100,7 +100,8 @@ time), primitive layers (Layer ▸ New ▸ Cube/Sphere/Plane/Torus/Cone/Cylinder
Geometry Options), text and shape layers with Geometry Options ▸ Extrusion Depth (glyph and
fill outlines flattened, triangulated with holes and extruded with Angular/Concave/Convex
bevels, per character so per-character 3D carries over), and a textured card for every other
3D layer. Shading is physically based in linear light (glTF metallic-roughness; Cook–Torrance
3D layer. Classic 3D doesn't draw model or primitive layers, so adding one switches a Classic 3D
comp to Advanced 3D in the same undo step, as in After Effects. Shading is physically based in linear light (glTF metallic-roughness; Cook–Torrance
GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013):
parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based
light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸