M7.6: extrusion + primitives

Extruded text keeps per-character 3D: each glyph's outline is extruded in its own space and
placed by its animated matrix; shape layers extrude their fill outlines (gradients use their
mid colour). Shadow-map bias scales with the texel footprint at the receiver and the PCF
reach (no acne on curved casters), identically on CPU and GPU. Docs: Advanced 3D in
architecture.md, parity table updated (3D, Text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 21:36:59 -04:00
co-authored by Claude Opus 5.5
parent dec091e575
commit 79ad7e1843
7 changed files with 55 additions and 21 deletions
+2 -1
View File
@@ -197,7 +197,8 @@ fn shadow_factor(m: u32, p: vec3<f32>, n_l: f32) -> f32 {
v = q.y * focal / q.z + c;
}
let z = q.z;
let bias = extra.x * (1.0 + 2.0 * (1.0 - clamp(n_l, 0.0, 1.0))) + abs(z) * 0.0015;
let texel = select(abs(z) / focal, 1.0 / focal, ortho);
let bias = extra.x * texel * (1.0 + extra.y) * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0)));
let step = extra.y / 2.0;
let sz = i32(size);
var lit = 0.0;
+3 -3
View File
@@ -460,12 +460,12 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
0 => {
let eye = [centre[0] - dir[0] * radius * 2.0, centre[1] - dir[1] * radius * 2.0, centre[2] - dir[2] * radius * 2.0];
let focal = (size as f64 * 0.5 / radius) as f32;
maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5 / focal, radius: soft });
maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft });
}
1 => {
let half = (l.cos_outer.clamp(-1.0, 1.0).acos() as f64 + 0.05).min(1.45);
let focal = (size as f64 * 0.5 / half.tan()) as f32;
maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.0, radius: soft });
maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.5, radius: soft });
}
_ => {
let fs = size / 2;
@@ -477,7 +477,7 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
] {
maps.push(ShadowMap { view: basis(pos, f, d), focal: fs as f32 * 0.5, ortho: false, size: fs, offset: 0, bias: 1.0, radius: soft });
maps.push(ShadowMap { view: basis(pos, f, d), focal: fs as f32 * 0.5, ortho: false, size: fs, offset: 0, bias: 1.5, radius: soft });
}
}
}
+13 -10
View File
@@ -152,7 +152,7 @@ pub struct ShadowMap {
pub ortho: bool,
pub size: u32,
pub offset: u32,
/// Depth bias (world units).
/// Depth bias in texels (scaled by the texel footprint at the receiver).
pub bias: f32,
/// PCF spread (texels).
pub radius: f32,
@@ -531,33 +531,35 @@ pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option<model::extrude::
})
}
/// Extruded meshes of a text or shape layer: (mesh in layer space, straight colour).
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4])> {
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4])> = vec![];
/// Extruded meshes of a text or shape layer: (mesh, straight colour, mesh space → layer
/// space). Text extrudes each character in its own space, so per-character 3D animation
/// (position, rotation, scale in Z) carries over.
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4], Mat4)> {
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4)> = vec![];
match layer.source {
LayerSource::Text => {
if let Some(tg) = crate::text::text_geom(ctx, layer) {
for g in tg.glyphs.iter().filter(|g| g.apply_fill && g.fill[3] > 0.0) {
groups.push((vec![g.path()], g.fill));
groups.push((vec![g.local.clone()], g.fill, g.m));
}
}
}
LayerSource::Shape => {
if let Some(c) = layer.props.sub("contents") {
groups = crate::shapes::fill_outlines(ctx, layer, c);
groups = crate::shapes::fill_outlines(ctx, layer, c).into_iter().map(|(ps, c)| (ps, c, Mat4::IDENTITY)).collect();
}
}
_ => {}
}
groups
.into_iter()
.filter_map(|(paths, color)| {
.filter_map(|(paths, color, m)| {
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
return None;
}
Some((model::extrude::extrude(&outlines, p), color))
Some((model::extrude::extrude(&outlines, p), color, m))
})
.collect()
}
@@ -567,7 +569,7 @@ fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude
let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let world = ctx.world_matrix(layer);
let lin = b.s.linear_io;
for (mesh, c) in extruded_meshes(ctx, layer, p) {
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]],
@@ -576,7 +578,8 @@ fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude
double_sided: false,
..base
});
b.emit(&mesh, m, &|_| world);
let xf = world * local;
b.emit(&mesh, m, &|_| xf);
}
}
+3 -1
View File
@@ -212,7 +212,9 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], n_l: f32) -> f3
let Some((u, v, z)) = shadow_coord(m, p) else { return 1.0 };
let size = m.size as i64;
// Slope-scaled bias (world units are pixels).
let bias = m.bias * (1.0 + 2.0 * (1.0 - n_l.clamp(0.0, 1.0))) + z.abs() * 0.0015;
// Texel footprint (world units) × PCF reach, steeper for grazing light.
let texel = if m.ortho { 1.0 / m.focal } else { z.abs() / m.focal };
let bias = m.bias * texel * (1.0 + m.radius) * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0)));
let step = m.radius / 2.0;
let mut lit = 0.0;
for j in -2..=2 {
+1 -1
View File
@@ -308,7 +308,7 @@ fn extruded_text_renders_as_a_mesh() {
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
assert!(!meshes.is_empty());
assert!(meshes.iter().all(|(m, _)| m.positions.iter().any(|q| (q[2] - 30.0).abs() < 1e-3)));
assert!(meshes.iter().all(|(m, _, _)| m.positions.iter().any(|q| (q[2] - 30.0).abs() < 1e-3)));
let img = render(&p, cid);
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
}
+29 -1
View File
@@ -69,7 +69,35 @@ the **source** (solid, footage frame, text, shape contents, or a nested comp), a
then **effects** in order, then **layer styles**, then the **transform** into comp space (with motion blur sub-samples
when enabled), the **track matte**, and finally **blends** into the accumulator. Adjustment layers
apply their effects to the accumulator. Runs of 3D layers are composited per pixel through the
active camera, with lights, shadows and depth of field.
active camera, with lights, shadows and depth of field (Classic 3D), or rasterised as meshes by
the Advanced 3D renderer (below).
**Advanced 3D** (Composition Settings ▸ 3D Renderer ▸ Advanced 3D, `comp.renderer`;
`crates/render/src/three_d/adv`, `crates/model`). A run of 3D layers becomes one scene of
world-space triangles: 3D model layers (glTF 2.0 / OBJ footage, mapped from +Y-up model units
into the layer by Geometry Options ▸ Model Scale; skins and animation clips played along layer
time), primitive layers (Layer ▸ New ▸ Cube/Sphere/Plane/Torus/Cone/Cylinder, parametric in
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
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 ▸
Environment Layer) is an equirectangular image (cosine irradiance map; roughness-indexed mip
chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps
(spot: perspective, parallel: orthographic over the scene, point: six cube faces). Cards keep
Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular);
models and primitives add Base Color, Metallic, Roughness, Emissive. Rasterisation runs at 2×2
supersampling with a reversed-Z depth buffer; opaque triangles resolve visibility first, the
transparent ones are sorted back to front and blended. The software rasteriser
(`adv::raster`) is the reference and the headless/CI path; `effectcraft-gpu` implements
`Accelerator::raster_3d` with a wgpu render pipeline (`advanced3d.wgsl`) that repeats the
shading step for step, and tests compare the two. Resolve, depth of field (a gather blur by
each pixel's circle of confusion from the camera's Depth of Field settings) and the conversion
back to the working encoding run on the CPU for both. Not yet: motion blur, track mattes and
blend modes inside an Advanced 3D run, collapsed 3D precomps (drawn flattened), morph targets,
extruded strokes.
A **layer cache** keeps each layer's finished pixels (source, masks and effects) keyed by a hash of
its evaluated inputs, excluding the transform. Static and transform-only layers render once;
+4 -4
View File
@@ -74,7 +74,7 @@ library groundwork for mask tracking and Warp Stabilizer.
| Wall clock, five parallel agents | 42–50 h | **≈ 8–10 h at today's measured pace; ≈ 25 h by the audit's conservative figures** |
What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker,
Advanced 3D (models, PBR, image-based lighting), the Warp Stabilizer and mask-tracking UI on top of
the Warp Stabilizer and mask-tracking UI on top of
the new tracking library, a JavaScript scripting object model, SVG/PSD import, WebM, a disk cache,
the missing effect categories (3D Channel, Immersive Video, OCIO), native macOS menus, and
wasm threads.
@@ -91,9 +91,9 @@ wasm threads.
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | ≈ 85% | 1.5 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no text on 3D bevels; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Web | 60% | 2.5 | threads, browser storage, audio |
| 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts |
| 3D | 72% | 12.0 | 3D camera tracker, multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
| Effects | 58% | 13.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU versions of more effects, 41 missing effects |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
@@ -132,4 +132,4 @@ Mocha and Cineware are third-party and not counted.
The engine underneath (keyframes, expressions, shape operators, text animators, Classic 3D, all
38 blend modes, the render queue) is deep. Most of what is missing is interactive tooling in the
viewer, settings that are stored but not yet rendered, about 69 menu entries that are still
disabled, and the large systems: tracking, puppet, paint, roto, Advanced 3D and GPU rendering.
disabled, and the large systems: tracking, puppet, paint and roto (Advanced 3D landed in M7.4–M7.6).