//! The Advanced 3D scene: everything a rasteriser needs, flattened into world-space triangles //! and plain arrays (identical input for the CPU rasteriser and the GPU pipeline). //! //! - Model and primitive layers: their meshes (glTF/OBJ models are +Y up in model units and //! are mapped into the layer by `Model Scale` with Y and Z flipped, a 180° turn about X). //! - Extruded text and shape layers (Geometry Options ▸ Extrusion Depth > 0): bevelled meshes. //! - Every other 3D layer (footage, solids, comps, flat text and shapes): a textured card of //! its processed buffer, lit with its Material Options. //! - Lights from the comp's light layers, the Environment light's image (prefiltered for image //! based lighting), and shadow maps of the shadow-casting lights. //! //! Colours are linear light; textures and layer buffers are linearised from the sRGB curve //! unless the project already blends in linear light. use std::collections::HashMap; use std::sync::Arc; use effectcraft_effects::Buf; use effectcraft_geom::{Mat4, Vec3, vec3}; use effectcraft_model::{self as model, Primitive}; use effectcraft_project::{ItemKind, Layer, LayerSource, LightKind, PrimitiveKind}; use super::shade::srgb_to_linear; use crate::three_d::camera::{CameraState, NEAR}; use crate::three_d::compose::camera_for; use crate::three_d::light::{LightState, Material as ClassicMaterial}; use crate::{EvalCtx, Renderer}; /// One vertex in world space. #[derive(Clone, Copy, Debug, Default, PartialEq)] #[repr(C)] pub struct Vertex { pub pos: [f32; 3], pub normal: [f32; 3], pub uv: [f32; 2], pub tangent: [f32; 4], pub material: u32, } /// A material, resolved for shading. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Material { /// Linear RGBA factor. pub base: [f32; 4], pub metallic: f32, pub roughness: f32, pub emissive: [f32; 3], pub normal_scale: f32, pub occlusion_strength: f32, /// 0 opaque, 1 mask, 2 blend. pub alpha_mode: u32, pub alpha_cutoff: f32, /// Layer opacity (multiplies alpha). pub opacity: f32, /// Texture indices (−1 = none). pub tex_base: i32, pub tex_mr: i32, pub tex_normal: i32, pub tex_occlusion: i32, pub tex_emissive: i32, pub double_sided: bool, pub unlit: bool, pub accepts_lights: bool, pub receives_shadows: bool, /// "Accepts Shadows: Only": draws just the shadows it receives, as black × their amount. pub shadows_only: bool, pub casts_shadows: bool, /// Drawn (false for "Casts Shadows: Only"). pub visible: bool, /// Material Options weights (cards: Diffuse, Specular Intensity / 50%, Ambient). pub diffuse_k: f32, pub specular_k: f32, pub ambient_k: f32, } impl Default for Material { fn default() -> Self { Material { base: [1.0; 4], metallic: 0.0, roughness: 0.5, emissive: [0.0; 3], normal_scale: 1.0, occlusion_strength: 1.0, alpha_mode: 0, alpha_cutoff: 0.5, opacity: 1.0, tex_base: -1, tex_mr: -1, tex_normal: -1, tex_occlusion: -1, tex_emissive: -1, double_sided: false, unlit: false, accepts_lights: true, receives_shadows: true, shadows_only: false, casts_shadows: false, visible: true, diffuse_k: 1.0, specular_k: 1.0, ambient_k: 1.0, } } } impl Material { /// Drawn in the transparent (sorted, blended) pass. pub fn transparent(&self) -> bool { self.alpha_mode == 2 || self.opacity < 0.999 || self.shadows_only } } /// A texture in [`Scene::texels`]: linear straight RGBA. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TexInfo { pub offset: u32, pub width: u32, pub height: u32, /// 0 repeat, 1 clamp, 2 mirror. pub wrap_u: u32, pub wrap_v: u32, } /// A light (ambient lights are summed into [`Scene::ambient`]). #[derive(Clone, Copy, Debug, PartialEq)] pub struct Light { /// 0 parallel, 1 spot, 2 point. pub kind: u32, pub pos: [f32; 3], /// Direction the light travels. pub dir: [f32; 3], /// Linear colour × intensity. pub color: [f32; 3], pub cos_inner: f32, pub cos_outer: f32, /// 0 none, 1 smooth, 2 inverse square clamped. pub falloff: u32, pub radius: f32, pub falloff_distance: f32, /// First shadow map (point lights use six), −1 = none. pub shadow: i32, pub shadow_darkness: f32, /// Shadow Diffusion (pixels). pub shadow_diffusion: f32, } /// A depth map seen from a light: `view` maps world → light space (+z forward); pixels /// `u = focal·x/z + size/2` (perspective) or `focal·x + size/2` (orthographic); texels store /// the light-space depth of the nearest caster. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ShadowMap { pub view: [[f32; 4]; 3], pub focal: f32, pub ortho: bool, pub size: u32, pub offset: u32, /// Depth bias in texels (scaled by the texel footprint at the receiver). pub bias: f32, /// Penumbra width (output pixels) requested by Shadow Diffusion; the shader /// converts it to a tap reach via the receiver's texel footprint. pub radius: f32, } /// The environment image: radiance mip levels `radiance..radiance + mips` and an irradiance /// map, in [`Scene::textures`]. #[derive(Clone, Copy, Debug, PartialEq)] pub struct EnvInfo { pub radiance: u32, pub mips: u32, pub irradiance: u32, pub intensity: f32, /// Radians about the vertical axis. pub rotation: f32, /// Unit direction from the scene towards the environment's dominant (radiance-weighted) /// bright region — the direction its shadow is cast from, like a parallel light's /// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright /// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow. pub sun: [f32; 3], /// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the /// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment /// light's shadow occludes only this much of its light: the key lobe is blocked where /// the map says so, the remaining fill keeps shining, so the shadow stays partial the /// way After Effects renders an area-light shadow (a broad environment like the default /// studio shadows softly, a lone bright spot on black almost fully). pub key: f32, /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), /// -1 casts no shadow. Gated by the light's Cast Shadows. pub shadow: i32, pub shadow_darkness: f32, /// The light's Shadow Diffusion (the penumbra width the shadow map's PCF widens to). pub shadow_diffusion: f32, } #[derive(Clone, Debug, Default)] pub struct Scene { /// Raster size (output × `ssaa`). pub width: u32, pub height: u32, pub ssaa: u32, /// World → clip (row-major; reversed depth: ndc z = near / camera z). pub clip: [[f32; 4]; 4], /// World → camera (row-major; camera z = depth for depth of field). pub view: [[f32; 4]; 4], pub eye: [f32; 3], pub cam_fwd: [f32; 3], pub ortho: bool, pub vertices: Vec, /// Triangles: the first `opaque_count` indices are opaque, the rest transparent, sorted /// back to front. pub indices: Vec, pub opaque_count: u32, pub materials: Vec, pub textures: Vec, pub texels: Vec<[f32; 4]>, pub lights: Vec, pub shadows: Vec, pub shadow_texels: Vec, pub env: Option, pub ambient: [f32; 3], /// Shading inputs and outputs are linear (no sRGB conversion needed). pub linear_io: bool, } /// Row-major f32 copy of a matrix. pub fn m32(m: &Mat4) -> [[f32; 4]; 4] { m.0.map(|r| r.map(|v| v as f32)) } /// Builds a [`Scene`]: texture uploads deduplicated by source. pub(crate) struct Builder { pub s: Scene, tex_keys: HashMap<(usize, bool), i32>, /// Per triangle: transparent and its sort depth. tri_depth: Vec<(bool, f32)>, cam: CameraState, /// World of the collapsed precomp layers the current layers are drawn through (identity at /// the top level). outer: Mat4, } impl Builder { fn texture_from(&mut self, w: u32, h: u32, wrap: [u32; 2], data: impl Iterator) -> i32 { let offset = self.s.texels.len() as u32; self.s.texels.extend(data); self.s.textures.push(TexInfo { offset, width: w, height: h, wrap_u: wrap[0], wrap_v: wrap[1] }); (self.s.textures.len() - 1) as i32 } /// Upload a model texture (`srgb`: colour data to linearise). fn model_texture(&mut self, t: &Arc, srgb: bool) -> i32 { let key = (Arc::as_ptr(t) as usize, srgb); if let Some(&i) = self.tex_keys.get(&key) { return i; } let lin = srgb && !self.s.linear_io; let wrap = t.wrap.map(|w| match w { model::Wrap::Repeat => 0, model::Wrap::Clamp => 1, model::Wrap::Mirror => 2, }); let i = self.texture_from( t.width, t.height, wrap, t.data.iter().map(|p| if lin { [srgb_to_linear(p[0]), srgb_to_linear(p[1]), srgb_to_linear(p[2]), p[3]] } else { *p }), ); self.tex_keys.insert(key, i); i } /// Upload a premultiplied layer buffer as a linear straight-alpha texture (clamped edges). fn buf_texture(&mut self, b: &Buf) -> (i32, bool) { let lin = !self.s.linear_io; let mut opaque = true; let data: Vec<[f32; 4]> = b .img .data .iter() .map(|p| { let a = p[3]; if a < 0.999 { opaque = false; } if a <= 1e-6 { return [0.0; 4]; } let s = [p[0] / a, p[1] / a, p[2] / a]; if lin { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), a] } else { [s[0], s[1], s[2], a] } }) .collect(); (self.texture_from(b.img.width, b.img.height, [1, 1], data.into_iter()), opaque) } fn material(&mut self, m: Material) -> u32 { self.s.materials.push(m); (self.s.materials.len() - 1) as u32 } /// Append a primitive with per-vertex matrices (model/layer space → world). fn emit(&mut self, p: &Primitive, mat: u32, xf: &dyn Fn(usize) -> Mat4) { if p.positions.is_empty() || p.indices.len() < 3 { return; } let base = self.s.vertices.len() as u32; let transparent = self.s.materials[mat as usize].transparent(); let uniform = xf(usize::MAX); let normal_of = |m: &Mat4| -> (Mat4, f64) { let a = &m.0; let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]); let inv = Mat4([[a[0][0], a[0][1], a[0][2], 0.0], [a[1][0], a[1][1], a[1][2], 0.0], [a[2][0], a[2][1], a[2][2], 0.0], [0.0, 0.0, 0.0, 1.0]]) .inverse() .unwrap_or(Mat4::IDENTITY); (inv.transpose(), det) }; let (un, udet) = normal_of(&uniform); let mut flip_count = 0usize; for k in 0..p.positions.len() { let (m, n, det) = if p.joints.is_empty() { (uniform, un, udet) } else { let m = xf(k); let (n, d) = normal_of(&m); (m, n, d) }; if det < 0.0 { flip_count += 1; } let q = p.positions[k]; let w = m.apply(vec3(q[0] as f64, q[1] as f64, q[2] as f64)); let nn = p.normals.get(k).copied().unwrap_or([0.0, 0.0, 1.0]); let wn = n.apply_vec(vec3(nn[0] as f64, nn[1] as f64, nn[2] as f64)).normalize(); let t = p.tangents.get(k).copied().unwrap_or([1.0, 0.0, 0.0, 1.0]); let wt = m.apply_vec(vec3(t[0] as f64, t[1] as f64, t[2] as f64)).normalize(); // Degenerate normals and tangents (zero length: NaN once normalised) get the shader's // fallbacks here, so every rasteriser shades them alike (GPU compilers may not keep // NaN comparisons). let finite = |v: Vec3, or: [f32; 3]| if v.x.is_finite() && v.y.is_finite() && v.z.is_finite() { [v.x as f32, v.y as f32, v.z as f32] } else { or }; let [tx, ty, tz] = finite(wt, [1.0, 0.0, 0.0]); self.s.vertices.push(Vertex { pos: [w.x as f32, w.y as f32, w.z as f32], normal: finite(wn, [0.0, 0.0, -1.0]), uv: p.uvs.get(k).copied().unwrap_or([0.0, 0.0]), tangent: [tx, ty, tz, if det < 0.0 { -t[3] } else { t[3] }], material: mat, }); } // Mirroring transforms reverse the winding. let flip = flip_count * 2 > p.positions.len(); for t in p.indices.as_chunks::<3>().0 { let (a, b, c) = (t[0] + base, t[1] + base, t[2] + base); if flip { self.s.indices.extend([a, c, b]) } else { self.s.indices.extend([a, b, c]) } let z = [a, b, c].iter().map(|&i| self.cam.depth(Vec3::from(self.s.vertices[i as usize].pos.map(|v| v as f64)))).sum::() / 3.0; self.tri_depth.push((transparent, z as f32)); } } /// Opaque triangles first, then transparent ones back to front. fn sort_triangles(&mut self) { let tris: Vec<[u32; 3]> = self.s.indices.as_chunks::<3>().0.iter().map(|t| [t[0], t[1], t[2]]).collect(); let mut order: Vec = (0..tris.len()).collect(); order.sort_by(|&a, &b| { let (ta, za) = self.tri_depth[a]; let (tb, zb) = self.tri_depth[b]; ta.cmp(&tb).then_with(|| if ta { zb.total_cmp(&za) } else { std::cmp::Ordering::Equal }) }); self.s.opaque_count = 3 * self.tri_depth.iter().filter(|t| !t.0).count() as u32; self.s.indices = order.into_iter().flat_map(|i| tris[i]).collect(); } } /// The Material Options of a card (classic properties). fn card_material(ctx: &EvalCtx, layer: &Layer) -> Material { let c = ClassicMaterial::of(ctx, layer); Material { roughness: (1.0 - c.shininess).clamp(0.05, 1.0), double_sided: true, accepts_lights: c.accepts_lights, receives_shadows: c.accepts_shadows != 0, shadows_only: c.accepts_shadows == 2, casts_shadows: c.casts_shadows != 0, visible: c.casts_shadows != 2, diffuse_k: c.diffuse, specular_k: c.specular * 2.0, ambient_k: c.ambient, ..Material::default() } } /// Shadow/light switches and the primitive material of a model or primitive layer. fn model_options(ctx: &EvalCtx, layer: &Layer, lin: bool) -> (Material, Option) { let g = layer.props.sub("materialOptions"); let e = |m: &str, d: u32| g.and_then(|g| g.get(m)).map_or(d, |p| ctx.value(layer, p).as_enum()); let b = |m: &str| g.and_then(|g| g.get(m)).is_none_or(|p| ctx.value(layer, p).as_bool()); let casts = e("castsShadows", 0); let accepts = e("acceptsShadows", 1); let opts = Material { accepts_lights: b("acceptsLights"), receives_shadows: accepts != 0, shadows_only: accepts == 2, casts_shadows: casts != 0, visible: casts != 2, ..Material::default() }; let prim = match (layer.source.clone(), g) { (LayerSource::Primitive { .. }, Some(g)) => { let c = ctx.color(layer, g, "baseColor"); let em = ctx.color(layer, g, "emissive"); let l = |v: f32| if lin { v } else { srgb_to_linear(v) }; let mut m = model::Material::plain( [l(c[0]), l(c[1]), l(c[2]), 1.0], (ctx.f(layer, g, "metallic", 0.0) / 100.0).clamp(0.0, 1.0) as f32, (ctx.f(layer, g, "roughness", 50.0) / 100.0).clamp(0.0, 1.0) as f32, ); m.emissive = [l(em[0]), l(em[1]), l(em[2])]; Some(m) } _ => None, }; (opts, prim) } fn prim_kind(k: PrimitiveKind) -> model::prim::PrimitiveKind { use model::prim::PrimitiveKind as M; match k { PrimitiveKind::Cube => M::Cube, PrimitiveKind::Sphere => M::Sphere, PrimitiveKind::Plane => M::Plane, PrimitiveKind::Torus => M::Torus, PrimitiveKind::Cone => M::Cone, PrimitiveKind::Cylinder => M::Cylinder, } } /// The primitive model of a primitive layer at the context time. pub fn primitive_model(ctx: &EvalCtx, layer: &Layer, kind: PrimitiveKind, mat: model::Material) -> model::Model { let g = layer.props.sub("geometryOptions"); let f = |m: &str, d: f64| g.map_or(d, |g| ctx.f(layer, g, m, d)); let pr = model::prim::PrimParams { size: [f("width", 300.0) as f32, f("height", 300.0) as f32, f("depth", 300.0) as f32], radius: f("radius", 150.0) as f32, tube: f("tubeRadius", 50.0) as f32, height: f("height", 300.0) as f32, segments: f("segments", 48.0).round().clamp(3.0, 512.0) as u32, rings: f("rings", 24.0).round().clamp(2.0, 512.0) as u32, }; model::prim::model(prim_kind(kind), &pr, mat) } /// Model space → layer space: model units to pixels, Y up → Y down (a 180° turn about X). pub fn model_to_layer(unit_scale: f64) -> Mat4 { Mat4::scale(vec3(unit_scale, -unit_scale, -unit_scale)) } fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let lin = b.s.linear_io; let (opts, prim_mat) = model_options(ctx, layer, lin); let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let geo = layer.props.sub("geometryOptions"); let (mdl, unit, clip, t): (Arc, f64, Option, f64) = match &layer.source { LayerSource::Primitive { kind } => (Arc::new(primitive_model(ctx, layer, *kind, prim_mat.unwrap_or_default())), 1.0, None, 0.0), LayerSource::Model { item } => { let Some(it) = r.project.item(*item) else { return }; let ItemKind::Footage(f) = &it.kind else { return }; let Some(m) = r.footage.model(*item, f) else { return }; let unit = geo.map_or(1.0, |g| ctx.f(layer, g, "unitScale", 1.0)); let clip = geo.map_or(0, |g| ctx.e(layer, g, "animation")) as usize; let clip = clip.checked_sub(1).filter(|c| *c < m.animations.len()); let speed = geo.map_or(100.0, |g| ctx.f(layer, g, "animationSpeed", 100.0)) / 100.0; let mut t = layer.layer_time(ctx.time).seconds() * speed; if let Some(c) = clip { let d = m.animations[c].duration(); let looping = geo.is_none_or(|g| ctx.b(layer, g, "loopAnimation")); if looping && d > 0.0 { t = t.rem_euclid(d); } } (m, unit, clip, t) } _ => return, }; let world = b.outer * ctx.world_matrix(layer) * model_to_layer(unit); // Materials of the model (one default when it has none). let mut mats: Vec = vec![]; let defaults = [model::Material::plain([0.8, 0.8, 0.8, 1.0], 0.0, 0.5)]; let src: &[model::Material] = if mdl.materials.is_empty() { &defaults } else { &mdl.materials }; for mm in src { let tex = |b: &mut Builder, t: Option, srgb: bool| -> i32 { t.and_then(|t| mdl.textures.get(t.texture)).map_or(-1, |t| b.model_texture(t, srgb)) }; let m = Material { base: mm.base_color, metallic: mm.metallic, roughness: mm.roughness, emissive: mm.emissive, normal_scale: mm.normal_scale, occlusion_strength: mm.occlusion_strength, alpha_mode: match mm.alpha_mode { model::AlphaMode::Opaque => 0, model::AlphaMode::Mask => 1, model::AlphaMode::Blend => 2, }, alpha_cutoff: mm.alpha_cutoff, opacity, tex_base: tex(b, mm.base_color_tex, true), tex_mr: tex(b, mm.metallic_roughness_tex, false), tex_normal: tex(b, mm.normal_tex, false), tex_occlusion: tex(b, mm.occlusion_tex, false), tex_emissive: tex(b, mm.emissive_tex, true), double_sided: mm.double_sided, unlit: mm.unlit, ..opts }; mats.push(b.material(m)); } for inst in mdl.instances(clip, t) { for p in &inst.mesh.primitives { let mat = mats[p.material.unwrap_or(0).min(mats.len() - 1)]; b.emit(p, mat, &|k| if k == usize::MAX { world * inst.world } else { world * model::vertex_matrix(&inst, p, k) }); } } } /// Closed polylines of a path (flattened to `tol` pixels). pub fn contours(path: &kurbo::BezPath, tol: f64) -> Vec> { let mut out: Vec> = vec![]; let mut cur: Vec<[f64; 2]> = vec![]; kurbo::flatten(path, tol, |el| match el { kurbo::PathEl::MoveTo(p) => { if cur.len() >= 3 { out.push(std::mem::take(&mut cur)); } cur = vec![[p.x, p.y]]; } kurbo::PathEl::LineTo(p) => cur.push([p.x, p.y]), kurbo::PathEl::ClosePath => { if cur.len() >= 3 { out.push(std::mem::take(&mut cur)); } cur.clear(); } _ => {} }); if cur.len() >= 3 { out.push(cur); } out } /// Geometry Options of a text/shape layer when it is extruded. pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option { let g = layer.props.sub("geometryOptions")?; let depth = ctx.f(layer, g, "extrusionDepth", 0.0); (depth > 0.0).then(|| model::extrude::ExtrudeParams { bevel: model::extrude::BevelStyle::from_index(ctx.e(layer, g, "bevelStyle")), bevel_depth: ctx.f(layer, g, "bevelDepth", 2.0).max(0.0), hole_bevel: (ctx.f(layer, g, "holeBevelDepth", 100.0) / 100.0).clamp(0.0, 1.0), depth, }) } /// Extruded meshes of a text or shape layer: (mesh, straight colour, mesh space → layer /// space). Fills and strokes are both extruded (a stroke as the region it covers, bevelled like /// the fill), stacked in the layer's paint order (see [`model::extrude::extrude_stacked`]). /// Text extrudes each character in its own space, so per-character 3D animation (position, /// rotation, scale in Z) carries over. A paint whose Front / Bevel / Side / Back are coloured /// (shape surface items, text animators) comes as one mesh per surface, in that order. pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4], Mat4)> { // (paths, colour, mesh → layer, stacking level, surface colours) let mut groups: Vec<(Vec, [f32; 4], Mat4, usize, crate::shapes::SurfaceColors)> = vec![]; match layer.source { LayerSource::Text => { if let Some(tg) = crate::text::text_geom(ctx, layer) { // Fill & Stroke order: all strokes over all fills (2), all fills over all strokes // (1), else the character style's Stroke Over Fill. let stroke_on_top = match tg.fill_stroke { 1 => false, 2 => true, _ => tg.doc.stroke_over_fill, }; for g in &tg.glyphs { let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; if g.apply_fill && g.fill[3] * op > 0.0 { let c = [g.fill[0], g.fill[1], g.fill[2], g.fill[3] * op]; groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c))); } if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 { let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) { let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op]; groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c))); } } } } } LayerSource::Shape => { if let Some(c) = layer.props.sub("contents") { groups = crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c, s))| (ps, c, Mat4::IDENTITY, k, s)).collect(); } } _ => {} } let mut out = vec![]; for (paths, color, m, level, surfaces) in groups { let cs: Vec> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect(); let outlines = model::extrude::group_contours(cs); if outlines.is_empty() { continue; } if surfaces.iter().all(Option::is_none) { out.push((model::extrude::extrude_stacked(&outlines, p, level), color, m)); continue; } for (mesh, s) in model::extrude::extrude_surfaces_stacked(&outlines, p, level).into_iter().zip(surfaces) { if mesh.indices.is_empty() { continue; } let c = s.map_or(color, |s| [s[0], s[1], s[2], color[3]]); out.push((mesh, c, m)); } } out } fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) { let base = card_material(ctx, layer); let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let world = b.outer * ctx.world_matrix(layer); let lin = b.s.linear_io; for (mesh, c, local) in extruded_meshes(ctx, layer, p) { let l = |v: f32| if lin { v } else { srgb_to_linear(v) }; let m = b.material(Material { base: [l(c[0]), l(c[1]), l(c[2]), c[3]], alpha_mode: if c[3] < 0.999 { 2 } else { 0 }, opacity, double_sided: false, ..base }); let xf = world * local; b.emit(&mesh, m, &|_| xf); } } fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) { if buf.img.width == 0 || buf.img.height == 0 { return; } let (tex, opaque) = b.buf_texture(buf); mat.tex_base = tex; mat.alpha_mode = if opaque { 0 } else { 2 }; let m = b.material(mat); let s = buf.scale.max(1e-9); let (x0, y0) = (-buf.offset[0] / s, -buf.offset[1] / s); let (x1, y1) = ((buf.img.width as f64 - buf.offset[0]) / s, (buf.img.height as f64 - buf.offset[1]) / s); // Faces the camera (−z) in layer space; uv (0,0) at the buffer's top-left texel corner. let p = Primitive { positions: vec![[x0 as f32, y0 as f32, 0.0], [x1 as f32, y0 as f32, 0.0], [x1 as f32, y1 as f32, 0.0], [x0 as f32, y1 as f32, 0.0]], normals: vec![[0.0, 0.0, -1.0]; 4], uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], tangents: vec![[1.0, 0.0, 0.0, 1.0]; 4], indices: vec![0, 2, 1, 0, 3, 2], material: Some(0), ..Default::default() }; b.emit(&p, m, &|_| world); } fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let mut mat = card_material(ctx, layer); mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); let world = b.outer * ctx.world_matrix(layer); if crate::text::per_char_3d(ctx, layer) { for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) { let buf = r.to_blend(Arc::new(buf), None); card(b, &buf, world * m, mat); } return; } if let Some(buf) = r.blend_layer_buf(ctx, layer) { card(b, &buf, world, mat); } } /// The evaluated Environment light: its image source, intensity × colour, rotation, and /// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights). #[derive(Clone, Copy)] struct EnvLight { src: Option, color: [f32; 3], rot: f32, casts_shadows: bool, shadow_darkness: f64, shadow_diffusion: f64, } /// Evaluated lights: (scene lights, ambient sum, environment light). fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option) { let mut out = vec![]; let mut ambient = [0.0f32; 3]; let mut env = None; let l = |v: f32| if lin { v } else { srgb_to_linear(v) }; for layer in ctx.comp.layers.iter().filter(|l| l.is_light() && l.switches.video && l.is_active_at(ctx.time)) { let LayerSource::Light { kind } = layer.source else { continue }; let Some(g) = layer.props.sub("lightOptions") else { continue }; let k = (ctx.f(layer, g, "intensity", 100.0) / 100.0) as f32; let c = ctx.color(layer, g, "color"); let color = [l(c[0]) * k, l(c[1]) * k, l(c[2]) * k]; match kind { LightKind::Ambient => { for i in 0..3 { ambient[i] += color[i]; } } LightKind::Environment => { if env.is_none() { let src = g.get("source").and_then(|p| ctx.value(layer, p).as_layer()); let rot = ctx.f(layer, g, "rotation", 0.0).to_radians() as f32; env = Some(EnvLight { src, color, rot, casts_shadows: ctx.b(layer, g, "castsShadows"), shadow_darkness: ctx.f(layer, g, "shadowDarkness", 100.0).clamp(0.0, 100.0) / 100.0, shadow_diffusion: ctx.f(layer, g, "shadowDiffusion", 0.0).max(0.0), }); } } _ => { let Some(st) = LightState::from_layer(ctx, layer) else { continue }; let f = |v: Vec3| [v.x as f32, v.y as f32, v.z as f32]; out.push(Light { kind: match kind { LightKind::Parallel => 0, LightKind::Spot => 1, _ => 2, }, pos: f(st.pos), dir: f(st.dir.normalize()), color, cos_inner: (st.cone_half * (1.0 - st.feather)).cos() as f32, cos_outer: st.cone_half.cos() as f32, falloff: st.falloff, radius: st.radius as f32, falloff_distance: st.falloff_distance as f32, // Marked; `raster::shadow_maps` assigns the maps. shadow: if st.casts_shadows { 0 } else { -1 }, shadow_darkness: st.shadow_darkness as f32, shadow_diffusion: st.shadow_diffusion as f32, }); } } } (out, ambient, env) } /// Box-downsample a straight RGBA image by 2 (odd sizes round up). fn half(w: u32, h: u32, d: &[[f32; 4]]) -> (u32, u32, Vec<[f32; 4]>) { let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1)); let mut out = vec![[0.0f32; 4]; (nw * nh) as usize]; for y in 0..nh { for x in 0..nw { let mut acc = [0.0f32; 4]; let mut n = 0.0; for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { let (sx, sy) = (2 * x + dx, 2 * y + dy); if sx < w && sy < h { let p = d[(sy * w + sx) as usize]; for k in 0..4 { acc[k] += p[k]; } n += 1.0; } } out[(y * nw + x) as usize] = acc.map(|v| v / n); } } (nw, nh, out) } /// Cosine-weighted irradiance (`w`×`h` equirectangular) of an equirectangular radiance image: /// each texel is the cosine-weighted mean radiance over the hemisphere around its direction /// (a constant environment gives the same constant). pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec<[f32; 4]> { use super::shade::{dot, equirect_dir}; // Source directions and solid-angle weights (∝ sin θ). let dirs: Vec<([f32; 3], f32, [f32; 3])> = (0..sh) .flat_map(|y| (0..sw).map(move |x| (x, y))) .map(|(x, y)| { let (u, v) = ((x as f32 + 0.5) / sw as f32, (y as f32 + 0.5) / sh as f32); let p = src[(y * sw + x) as usize]; (equirect_dir(u, v, 0.0), (v * std::f32::consts::PI).sin(), [p[0], p[1], p[2]]) }) .collect(); let mut out = vec![[0.0f32; 4]; (w * h) as usize]; for y in 0..h { for x in 0..w { let n = equirect_dir((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32, 0.0); let (mut acc, mut wsum) = ([0.0f32; 3], 0.0f32); for (d, sa, c) in &dirs { let k = dot(n, *d).max(0.0) * sa; if k > 0.0 { for i in 0..3 { acc[i] += c[i] * k; } wsum += k; } } let inv = if wsum > 0.0 { 1.0 / wsum } else { 0.0 }; out[(y * w + x) as usize] = [acc[0] * inv, acc[1] * inv, acc[2] * inv, 1.0]; } } out } /// The environment image's dominant direction and key strength: the radiance-weighted mean /// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image /// is directionless (a uniform environment cancels to zero, and opposing bright regions that /// cancel leave no mean either). The mean of a single bright spot /// on black points straight at it; a gradient points somewhere between its ends. The returned /// key fraction is the mean vector's length over the radiance weight — how directional the /// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment /// light's shadow can remove. pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> ([f32; 3], f32) { use super::shade::equirect_dir; let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); let mut weight = 0.0f32; for (i, p) in d.iter().enumerate() { let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32); let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32); // Luminance × solid angle (∝ sin θ). let lum = (p[0] + p[1] + p[2]) * (v * std::f32::consts::PI).sin(); let dir = equirect_dir(u, v, rot); x += dir[0] * lum; y += dir[1] * lum; z += dir[2] * lum; weight += lum.abs(); } // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow // from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then. let len = (x * x + y * y + z * z).sqrt(); if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 { return ([0.0; 3], 0.0); } ([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0)) } /// Prefilter an environment image into the scene's textures. pub(crate) fn add_environment( b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32, casts_shadows: bool, shadow_darkness: f64, shadow_diffusion: f64, ) { // The shadow direction and key strength from the full-resolution image, before the mip // chain averages it — only a shadow-casting light reads them, so only one pays for the pass. let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { (w, h, d) = half(w, h, &d); } let first = b.s.textures.len() as u32; let mut levels = 0; let mut small: Option<(u32, u32, Vec<[f32; 4]>)> = None; loop { b.texture_from(w, h, [0, 1], d.iter().copied()); levels += 1; if w <= 64 && small.is_none() { small = Some((w, h, d.clone())); } if w <= 8 || h <= 4 { break; } (w, h, d) = half(w, h, &d); } let (sw, sh, sd) = small.unwrap_or((w, h, d)); let irr = irradiance_map(sw, sh, &sd, 32, 16); let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32; // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { let tint = intensity.map(|c| c / k); for t in first..=ii { let ti = b.s.textures[t as usize]; for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] { for c in 0..3 { p[c] *= tint[c]; } } } } b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation, sun, key, // Marked; `raster::shadow_maps` assigns the map (a directionless image casts none). shadow: if casts_shadows { 0 } else { -1 }, shadow_darkness: shadow_darkness as f32, shadow_diffusion: shadow_diffusion as f32, }); } /// The Environment light's image: `Absent` when the light has no Source and the comp has no /// Environment Layer (the default studio environment applies — After Effects "uses a default /// environment map" for an Environment light too), `Image` when the source renders, `Failed` /// when a selected or found source produced no pixels (a deleted layer, missing footage, a /// failed decode). A failed source must not silently become the default environment — that /// would relight and reshadow the scene behind the user's back — so, like any layer whose /// source fails renders nothing, it contributes nothing (#2436). enum EnvPixels { Absent, Image((u32, u32, Vec<[f32; 4]>)), Failed, } fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> EnvPixels { let layer = match src { Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)), None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)), }; let Some(layer) = layer else { // A set Source that resolves to no layer (deleted) failed; an unset one with no // Environment Layer is simply unconfigured. return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent }; }; let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed }; let img = &buf.img; let data = img .data .iter() .map(|p| { let a = p[3]; if a <= 1e-6 { return [0.0, 0.0, 0.0, 1.0]; } let s = [p[0] / a, p[1] / a, p[2] / a]; if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] } }) .collect(); EnvPixels::Image((img.width, img.height, data)) } /// The default environment when the light has no Source and no Environment Layer: a dim studio /// gradient, bright above and toward the initial camera, dark below and behind — After /// Effects "uses a default environment map" for an Environment light too, and its default /// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience /// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which /// is not what After Effects' default looks like); the light's colour × intensity still scales /// the overall level, and its Rotation turns the key around the scene (#2436). pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { use super::shade::{dot, equirect_dir}; // The key region's centre: three quarters up, two thirds toward the viewer (the default // camera looks +z from -z). let key = [0.0f32, -0.75, -0.66]; let (w, h) = (8u32, 4u32); let mut data = Vec::with_capacity((w * h) as usize); let (mut mean, mut weight) = (0.0f32, 0.0f32); for y in 0..h { let v = (y as f32 + 0.5) / h as f32; let sa = (v * std::f32::consts::PI).sin(); for x in 0..w { let u = (x as f32 + 0.5) / w as f32; let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0); data.push([l, l, l, 1.0]); mean += l * sa; weight += sa; } } // A dim studio: scale the gradient to a weighted mean of exactly 0.3. let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 }; for p in &mut data { for c in 0..3 { p[c] *= k; } } (w, h, data) } /// Supersampling factor: 2×2 unless draft or very large. fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 { if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 } } /// Add one layer of a run: a model or primitive mesh, an extruded text/shape layer, the nested /// layers of a collapsed precomp, or a textured card. fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Builder) { if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { return; } if let Some(item) = r.collapsed(ctx, layer) { collapsed_layer(r, ctx, layer, item, b); } else if layer.source.is_model() { model_layer(r, ctx, layer, b); } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) && let Some(p) = extrusion_params(ctx, layer) { extruded_layer(r, ctx, layer, &p, b); } else { card_layer(r, ctx, layer, b); } } /// A collapsed precomp layer: its nested layers join this scene as real Advanced 3D geometry /// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they /// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp /// layer's plane; nested lights and cameras are ignored (the parent's light the scene). fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return }; let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; let any_solo = nctx.comp.any_solo(); for l in nctx.comp.layers.iter().rev() { if !l.is_active_at(t) || l.is_light() || matches!(l.source, LayerSource::Camera) || (any_solo && !l.switches.solo) || (!sub.opts.guides && l.switches.guide) { continue; } emit_layer(&sub, &nctx, l, b); } b.outer = saved; } /// Build the scene of a run of 3D layers. pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene { build_at(r, ctx, None, run, out) } /// Whether a layer's motion is blurred (the renderer's gate: Motion Blur switch, comp setting, /// render option and the precomp layers above). pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool { r.mb_on(ctx, layer) } /// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights /// are evaluated at `sub`'s time; the other layers stay at `ctx`'s. pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: &[&Layer], out: (u32, u32)) -> Scene { let base = ctx; let at = sub.unwrap_or(ctx); let ctx = at; let cam = camera_for(r, ctx); let lin = r.pipe.linear || r.pipe.linear_blend; let ssaa = ssaa_for(r, out); let mut b = Builder { s: Scene { width: out.0 * ssaa, height: out.1 * ssaa, ssaa, linear_io: lin, ..Scene::default() }, tex_keys: HashMap::new(), tri_depth: vec![], cam, outer: r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world), }; for layer in run { let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base }; emit_layer(r, ctx, layer, &mut b); } b.sort_triangles(); // Lights, environment, shadows (Draft 3D: unlit, like After Effects). A collapsed // precomp's layers are lit by the outermost comp's lights. let parent = r.collapse3d.map(|c| c.parent); let ctx = parent.as_ref().unwrap_or(ctx); if !ctx.comp.draft_3d { let (ls, amb, env) = lights(ctx, lin); b.s.lights = ls; b.s.ambient = amb; if let Some(env) = env { match env_pixels(r, ctx, env.src, lin) { EnvPixels::Image((w, h, d)) => { add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) } // No Source and no Environment Layer: the default environment, a soft studio // gradient of the light's colour × intensity — After Effects "uses a default // environment map" for an Environment light — so the light always lights the // scene per its parameters, and its dominant region casts shadows (#2436). EnvPixels::Absent => { let (w, h, d) = default_environment(); add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) } // A selected or found source that produced no pixels (deleted layer, missing // footage, failed decode): no environment at all — the light contributes // nothing, exactly like a layer whose source failed renders nothing, never a // silent swap to the default studio's lighting and shadows (#2436). EnvPixels::Failed => {} } } if r.opts.shadows() { super::raster::shadow_maps(&mut b.s, r.opts.draft); } else { // Settings ▸ 3D ▸ Realtime Shadows in Draft off: draft renders skip shadows. for l in &mut b.s.lights { l.shadow = -1; } if let Some(e) = &mut b.s.env { e.shadow = -1; } } } // Camera matrices. let s = r.opts.scale; let (cw, ch) = crate::three_d::compose::view_size(r, ctx); let (ow, oh) = (out.0 as f64, out.1 as f64); let (a, bb) = (2.0 * s * cam.zoom / ow, s * cw / ow - 1.0); let (c, d) = (2.0 * s * cam.zoom / oh, s * ch / oh - 1.0); let proj = if cam.ortho { let (mut zmin, mut zmax) = (f64::INFINITY, f64::NEG_INFINITY); for v in &b.s.vertices { let z = cam.depth(Vec3::from(v.pos.map(|x| x as f64))); zmin = zmin.min(z); zmax = zmax.max(z); } if !zmin.is_finite() { (zmin, zmax) = (-1.0, 1.0); } let pad = (zmax - zmin).abs() * 0.01 + 1.0; let (zmin, zmax) = (zmin - pad, zmax + pad); let zr = zmax - zmin; Mat4([[a, 0.0, 0.0, bb], [0.0, -c, 0.0, -d], [0.0, 0.0, -1.0 / zr, zmax / zr], [0.0, 0.0, 0.0, 1.0]]) } else { Mat4([[a, 0.0, bb, 0.0], [0.0, -c, -d, 0.0], [0.0, 0.0, 0.0, NEAR], [0.0, 0.0, 1.0, 0.0]]) }; b.s.clip = m32(&(proj * cam.view)); b.s.view = m32(&cam.view); let f = cam.forward(); b.s.cam_fwd = [f.x as f32, f.y as f32, f.z as f32]; b.s.eye = [cam.eye.x as f32, cam.eye.y as f32, cam.eye.z as f32]; b.s.ortho = cam.ortho; b.s }