mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 13:37:37 -06:00
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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 ▸
|
||||
|
||||
Reference in New Issue
Block a user