mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
FreeBSD's CI VM (Mesa llvmpipe over GL) failed 22 effectcraft-gpu tests: - Advanced 3D's camera-depth target was R32Float, which GLES can't render to without EXT_color_buffer_float: create_render_pipeline failed validation (release builds only log that and draw nothing). The target is now R32Uint holding the f32 bits (renderable on every backend, lossless), and GpuContext checks the adapter's format features (render attachment, blendable colour) and independent blending up front; when they're missing the render pipelines are never built and Advanced 3D renders on the CPU. - Signed `%` in the shaders: naga emits GLSL `%`, undefined for negative operands, which broke the VR effects' horizontal wrap and Cell Pattern's tiling. A shared `imod` never takes `%` of a negative number. - asin/acos: Mesa's are coarse approximations (~1e-4 rad), visible in every lens/sphere/cylinder mapping. `asin_p`/`acos_p` (Cephes' asinf polynomial) are f32-accurate on every backend. - "Could not lock adapter context": the GL backend has one context lock per adapter with a timeout, so tests driving devices concurrently panic. Every gpu test helper that hands out a device holds one process-wide lock for the calling test; ui_web_job_gpu's two device tests share a lock too. - On GL, up to 1 % of pixels may flip across a decision boundary (division through a reciprocal, unfused fma); the GPU-vs-CPU comparisons allow that there only. Also adds the FreeBSD workflow (from the FreeBSD CI PR) so this branch is checked there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
553 lines
28 KiB
Rust
553 lines
28 KiB
Rust
//! Advanced 3D: the GPU rasteriser against the CPU reference on the same scenes.
|
||
//!
|
||
//! Tolerances: the GPU's colour target is half float (relative error ≤ 1e-3) and its
|
||
//! rasteriser and interpolators differ from the CPU's in the last bits, so pixels on triangle
|
||
//! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most
|
||
//! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter.
|
||
|
||
use effectcraft_keyframe::Value;
|
||
use effectcraft_project::build;
|
||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid};
|
||
use effectcraft_render::three_d::adv::{self, Scene};
|
||
use effectcraft_render::{Accelerator, EvalCtx, NoFootage, RenderOpts, Renderer};
|
||
use effectcraft_time::{FrameRate, Tick};
|
||
|
||
use crate::Gpu;
|
||
|
||
/// A device of the test's own (the test holds [`crate::tests::hold_gpu_lock`]).
|
||
fn gpu() -> Option<Gpu> {
|
||
crate::tests::hold_gpu_lock();
|
||
let g = Gpu::headless();
|
||
if g.is_none() {
|
||
eprintln!("effectcraft-gpu adv3d tests: no GPU adapter, skipping");
|
||
}
|
||
g
|
||
}
|
||
|
||
fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||
let comp = p.comp(cid).unwrap().clone();
|
||
let mut l = build::layer(p, &comp, "L", src, (comp.width, comp.height), None);
|
||
edit(&mut l);
|
||
p.comp_mut(cid).unwrap().layers.insert(0, l);
|
||
}
|
||
|
||
fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) {
|
||
l.props.prop_mut(path).unwrap_or_else(|| panic!("{path}")).value = v;
|
||
}
|
||
|
||
/// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light,
|
||
/// an environment light, a semi-transparent textured card.
|
||
pub(crate) fn scene_project() -> (Project, ItemId) {
|
||
let mut p = Project::default();
|
||
let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||
c.renderer = R3::Advanced3D;
|
||
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
|
||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
|
||
set(l, "geometryOptions/width", Value::Scalar(900.0));
|
||
set(l, "geometryOptions/height", Value::Scalar(900.0));
|
||
set(l, "transform/position", Value::Vec3([120.0, 130.0, 0.0]));
|
||
set(l, "transform/rotationX", Value::Scalar(90.0));
|
||
});
|
||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
|
||
set(l, "geometryOptions/radius", Value::Scalar(35.0));
|
||
set(l, "transform/position", Value::Vec3([80.0, 95.0, 0.0]));
|
||
set(l, "materialOptions/baseColor", Value::Color([0.9, 0.2, 0.2, 1.0]));
|
||
set(l, "materialOptions/roughness", Value::Scalar(35.0));
|
||
set(l, "materialOptions/castsShadows", Value::Enum(1));
|
||
});
|
||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Torus }, |l| {
|
||
set(l, "geometryOptions/radius", Value::Scalar(35.0));
|
||
set(l, "geometryOptions/tubeRadius", Value::Scalar(12.0));
|
||
set(l, "transform/position", Value::Vec3([165.0, 90.0, 0.0]));
|
||
set(l, "transform/rotationX", Value::Scalar(60.0));
|
||
set(l, "materialOptions/metallic", Value::Scalar(100.0));
|
||
set(l, "materialOptions/roughness", Value::Scalar(20.0));
|
||
set(l, "materialOptions/castsShadows", Value::Enum(1));
|
||
});
|
||
let sid = p.add_item("S", Default::default(), None, ItemKind::Solid(Solid { color: [0.2, 0.6, 0.9], width: 60, height: 40, pixel_aspect: 1.0 }));
|
||
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| {
|
||
l.switches.three_d = true;
|
||
set(l, "transform/opacity", Value::Scalar(60.0));
|
||
set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0]));
|
||
set(l, "transform/rotationY", Value::Scalar(30.0));
|
||
});
|
||
let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 }));
|
||
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
|
||
l.environment = true;
|
||
l.switches.three_d = true;
|
||
});
|
||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.0)));
|
||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| {
|
||
set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0]));
|
||
set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0]));
|
||
set(l, "lightOptions/castsShadows", Value::Bool(true));
|
||
set(l, "lightOptions/coneAngle", Value::Scalar(110.0));
|
||
set(l, "lightOptions/shadowDiffusion", Value::Scalar(4.0));
|
||
});
|
||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
|
||
set(l, "transform/position", Value::Vec3([200.0, 20.0, -100.0]));
|
||
set(l, "lightOptions/intensity", Value::Scalar(40.0));
|
||
set(l, "lightOptions/falloff", Value::Enum(1));
|
||
});
|
||
(p, cid)
|
||
}
|
||
|
||
pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene {
|
||
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
|
||
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
|
||
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
|
||
}
|
||
|
||
fn compare(s: &Scene, g: &Gpu) {
|
||
let cpu = adv::raster::render(s);
|
||
let gt = g.raster_3d(s).expect("gpu raster");
|
||
assert_eq!((gt.width, gt.height), (cpu.width, cpu.height));
|
||
let n = cpu.color.len();
|
||
let mut bad = 0;
|
||
let mut sum = 0.0f64;
|
||
for (a, b) in cpu.color.iter().zip(>.color) {
|
||
let d = (0..4).map(|k| (a[k] - b[k]).abs() / a[k].abs().max(1.0)).fold(0.0f32, f32::max);
|
||
sum += d as f64;
|
||
if d > 2.0 / 255.0 {
|
||
bad += 1;
|
||
}
|
||
}
|
||
let frac = bad as f64 / n as f64;
|
||
eprintln!("adv3d gpu vs cpu: {bad}/{n} pixels beyond 2/255 ({:.3} %), mean {:.5}", frac * 100.0, sum / n as f64);
|
||
assert!(frac < 0.015, "{:.3} % of pixels differ", frac * 100.0);
|
||
assert!(sum / (n as f64) < 0.002);
|
||
// Depth agrees where both see a surface.
|
||
let mut dz = 0;
|
||
for (a, b) in cpu.depth.iter().zip(>.depth) {
|
||
if a.is_finite() != b.is_finite() || (a.is_finite() && (a - b).abs() > 0.01 * a.abs().max(1.0)) {
|
||
dz += 1;
|
||
}
|
||
}
|
||
assert!((dz as f64) < 0.015 * n as f64, "{dz} depth mismatches");
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_matches_cpu_lit_scene() {
|
||
let Some(g) = gpu() else { return };
|
||
let (p, cid) = scene_project();
|
||
let s = scene(&p, cid);
|
||
assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32);
|
||
compare(&s, &g);
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_matches_cpu_unlit_and_full_frames() {
|
||
let Some(g) = gpu() else { return };
|
||
let (mut p, cid) = scene_project();
|
||
// Unlit: remove the lights.
|
||
p.comp_mut(cid).unwrap().layers.retain(|l| !l.is_light());
|
||
compare(&scene(&p, cid), &g);
|
||
// Whole frames through the renderer with the GPU attached.
|
||
let (p, cid) = scene_project();
|
||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||
r.accel = Some(&g);
|
||
let gimg = r.comp_frame(cid, Tick::ZERO);
|
||
let diff = cpu.data.iter().zip(&gimg.data).filter(|(a, b)| (0..4).any(|k| (a[k] - b[k]).abs() > 3.0 / 255.0)).count();
|
||
assert!((diff as f64) < 0.02 * cpu.data.len() as f64, "{diff} pixels differ");
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_raster_perf_smoke() {
|
||
let Some(g) = gpu() else { return };
|
||
let (p, cid) = scene_project();
|
||
let s = scene(&p, cid);
|
||
let t0 = std::time::Instant::now();
|
||
let _ = adv::raster::render(&s);
|
||
let cpu = t0.elapsed();
|
||
// First use compiles the pipelines.
|
||
let _ = g.raster_3d(&s);
|
||
let t0 = std::time::Instant::now();
|
||
let _ = g.raster_3d(&s);
|
||
let gpu = t0.elapsed();
|
||
eprintln!("adv3d 480x320 (2x SSAA of 240x160), {} triangles: cpu {:?}, gpu {:?}", s.indices.len() / 3, cpu, gpu);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- whole runs (M13.11)
|
||
//
|
||
// `Accelerator::render_3d` (raster, resolve, motion-blur average, iris depth of field, encode
|
||
// on the GPU) against `adv::render_prepared` on the CPU. Tolerance: ≤ 1/255 on ≥ 99 % of the
|
||
// pixels. Against the CPU rasteriser the remaining pixels are silhouette pixels the two
|
||
// rasterisers cover differently (and the blur spreads); against the CPU post-processing of
|
||
// the *same* GPU raster the agreement is ≥ 99.9 % (f32 order of operations only).
|
||
|
||
/// The lit scene seen through a depth-of-field camera (hexagonal iris, diffraction fringe,
|
||
/// highlight boost) with the sphere moving under motion blur.
|
||
pub(crate) fn dof_mb_project() -> (Project, ItemId) {
|
||
let (mut p, cid) = scene_project();
|
||
add(&mut p, cid, LayerSource::Camera, |l| {
|
||
let zoom = match l.props.prop_mut("cameraOptions/zoom").unwrap().value {
|
||
Value::Scalar(z) => z,
|
||
_ => 300.0,
|
||
};
|
||
set(l, "cameraOptions/dof", Value::Bool(true));
|
||
set(l, "cameraOptions/focusDistance", Value::Scalar(zoom - 60.0));
|
||
set(l, "cameraOptions/aperture", Value::Scalar(160.0));
|
||
set(l, "cameraOptions/irisShape", Value::Enum(4));
|
||
set(l, "cameraOptions/irisRotation", Value::Scalar(15.0));
|
||
set(l, "cameraOptions/irisDiffractionFringe", Value::Scalar(50.0));
|
||
set(l, "cameraOptions/highlightGain", Value::Scalar(30.0));
|
||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(150.0));
|
||
});
|
||
let c = p.comp_mut(cid).unwrap();
|
||
c.enable_motion_blur = true;
|
||
c.motion_blur_samples = 6;
|
||
// The sphere moves 40 px per frame.
|
||
let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap();
|
||
sphere.switches.motion_blur = true;
|
||
sphere.props.prop_mut("transform/position").unwrap().keys = vec![
|
||
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])),
|
||
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([80.0 + 30.0 * 40.0, 95.0, 0.0])),
|
||
];
|
||
(p, cid)
|
||
}
|
||
|
||
/// Share of pixels (0–1) differing by more than 1/255 on any channel, and the largest
|
||
/// difference.
|
||
pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
|
||
let mut off = 0;
|
||
let mut max = 0.0f32;
|
||
for (p, q) in a.iter().zip(b) {
|
||
// NaN counts as a difference (f32::max would drop it).
|
||
let d = (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0f32, |a, b| if b.is_nan() || a.is_nan() { f32::NAN } else { a.max(b) });
|
||
max = max.max(d);
|
||
if d.is_nan() || d > 1.0 / 255.0 + 1e-6 {
|
||
off += 1;
|
||
}
|
||
}
|
||
(off as f64 / a.len().max(1) as f64, max)
|
||
}
|
||
|
||
pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) {
|
||
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
|
||
(ctx, run)
|
||
}
|
||
|
||
fn compare_run(p: &Project, cid: ItemId, g: &Gpu, what: &str) {
|
||
let (ctx, run) = run_of(p, cid);
|
||
let out = (ctx.comp.width, ctx.comp.height);
|
||
// CPU reference: CPU raster and post.
|
||
let cpu_r = Renderer::new(p, &NoFootage, RenderOpts::default());
|
||
let prep = adv::Prepared::new(&cpu_r, &ctx, &run, out);
|
||
let (cimg, cdepth) = adv::render_prepared(&prep).expect("cpu drew");
|
||
// The GPU: everything on the device.
|
||
let (gimg, gdepth) = g.render_3d(&prep).expect("gpu handled").expect("gpu drew");
|
||
let (share, max) = off_share(&cimg.data, &gimg.data);
|
||
eprintln!("{what}: gpu vs cpu {:.3} % of pixels beyond 1/255 (max {max:.4})", share * 100.0);
|
||
assert!(share <= 0.01, "{what}: {:.3} % of pixels differ", share * 100.0);
|
||
let dz = cdepth.iter().zip(&gdepth).filter(|(a, b)| a.is_finite() != b.is_finite() || (a.is_finite() && (*a - *b).abs() > 0.01 * a.abs().max(1.0))).count();
|
||
assert!((dz as f64) < 0.015 * cdepth.len() as f64, "{what}: {dz} depth mismatches");
|
||
// CPU post-processing of the GPU raster: the post kernels alone.
|
||
let mut gr = Renderer::new(p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||
gr.accel = Some(g);
|
||
let prep = adv::Prepared::new(&gr, &ctx, &run, out);
|
||
let (himg, _) = adv::render_prepared(&prep).expect("cpu post drew");
|
||
let (share, max) = off_share(&himg.data, &gimg.data);
|
||
eprintln!("{what}: gpu post vs cpu post on the gpu raster: {:.4} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||
assert!(share <= 0.001, "{what}: post kernels differ on {:.3} % of pixels", share * 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_runs_match_cpu_with_dof_and_motion_blur() {
|
||
let Some(g) = gpu() else { return };
|
||
let (p, cid) = dof_mb_project();
|
||
let (ctx, run) = run_of(&p, cid);
|
||
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||
let prep = adv::Prepared::new(&r, &ctx, &run, (240, 160));
|
||
assert_eq!(prep.samples, 6);
|
||
let dof = prep.dof.expect("depth of field");
|
||
assert_eq!(dof.iris.sides, 6);
|
||
compare_run(&p, cid, &g, "dof + motion blur");
|
||
// The depth of field really blurs (against the same frame in focus).
|
||
let (blurred, _) = g.render_3d(&prep).unwrap().unwrap();
|
||
let mut sharp_prep = adv::Prepared::new(&r, &ctx, &run, (240, 160));
|
||
sharp_prep.dof = None;
|
||
let (sharp, _) = g.render_3d(&sharp_prep).unwrap().unwrap();
|
||
let (changed, _) = off_share(&blurred.data, &sharp.data);
|
||
assert!(changed > 0.2, "out-of-focus pixels: {:.1} %", changed * 100.0);
|
||
// Fast Rectangle iris, no highlights; and without depth of field.
|
||
let (mut p, cid) = dof_mb_project();
|
||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||
if let Some(pr) = l.props.prop_mut("cameraOptions/irisShape") {
|
||
pr.value = Value::Enum(0);
|
||
}
|
||
if let Some(pr) = l.props.prop_mut("cameraOptions/highlightGain") {
|
||
pr.value = Value::Scalar(0.0);
|
||
}
|
||
}
|
||
compare_run(&p, cid, &g, "fast rectangle");
|
||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||
if let Some(pr) = l.props.prop_mut("cameraOptions/dof") {
|
||
pr.value = Value::Bool(false);
|
||
}
|
||
}
|
||
compare_run(&p, cid, &g, "motion blur only");
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_runs_match_cpu_for_extrusions_and_collapsed_precomps() {
|
||
let Some(g) = gpu() else { return };
|
||
let (mut p, cid) = scene_project();
|
||
// Extruded text with a bevelled stroke.
|
||
add(&mut p, cid, LayerSource::Text, |l| {
|
||
l.switches.three_d = true;
|
||
let doc = effectcraft_keyframe::TextDoc {
|
||
text: "Ab".into(),
|
||
size: 50.0,
|
||
fill: [0.9, 0.8, 0.2, 1.0],
|
||
apply_fill: true,
|
||
stroke: [0.1, 0.2, 0.9, 1.0],
|
||
apply_stroke: true,
|
||
stroke_width: 4.0,
|
||
..Default::default()
|
||
};
|
||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||
let mut next = 20_000u64;
|
||
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
|
||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(15.0));
|
||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||
set(l, "transform/position", Value::Vec3([120.0, 50.0, -30.0]));
|
||
set(l, "transform/rotationY", Value::Scalar(25.0));
|
||
});
|
||
// A collapsed precomp holding a cube.
|
||
let mut nc = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||
nc.renderer = R3::Classic3D;
|
||
let nid = p.add_item("N", Default::default(), None, ItemKind::Comp(nc.into()));
|
||
add(&mut p, nid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
|
||
for k in ["width", "height", "depth"] {
|
||
set(l, &format!("geometryOptions/{k}"), Value::Scalar(30.0));
|
||
}
|
||
set(l, "transform/position", Value::Vec3([200.0, 120.0, -20.0]));
|
||
set(l, "transform/rotationY", Value::Scalar(35.0));
|
||
set(l, "transform/rotationX", Value::Scalar(-25.0));
|
||
});
|
||
add(&mut p, cid, LayerSource::Comp { item: nid }, |l| {
|
||
l.switches.three_d = true;
|
||
l.switches.collapse = true;
|
||
});
|
||
let (ctx, run) = run_of(&p, cid);
|
||
let s = adv::scene_of(&Renderer::new(&p, &NoFootage, RenderOpts::default()), &ctx, &run, (240, 160));
|
||
assert!(s.indices.len() / 3 > 500, "meshes: {} triangles", s.indices.len() / 3);
|
||
compare_run(&p, cid, &g, "extrusions + collapsed precomp");
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_compositor_draws_advanced_3d_runs_on_the_device() {
|
||
let Some(g) = gpu() else { return };
|
||
// A 2D background below the run and a 2D layer above it.
|
||
let (mut p, cid) = dof_mb_project();
|
||
let bg = p.add_item("B", Default::default(), None, ItemKind::Solid(Solid { color: [0.1, 0.1, 0.15], width: 240, height: 160, pixel_aspect: 1.0 }));
|
||
let comp = p.comp(cid).unwrap().clone();
|
||
let l = build::layer(&mut p, &comp, "B", LayerSource::Solid { item: bg }, (240, 160), None);
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
let top = p.add_item("T", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 40, height: 30, pixel_aspect: 1.0 }));
|
||
add(&mut p, cid, LayerSource::Solid { item: top }, |l| set(l, "transform/opacity", Value::Scalar(50.0)));
|
||
let (ctx, run) = run_of(&p, cid);
|
||
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_some());
|
||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||
r.accel = Some(&g);
|
||
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
|
||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||
eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||
assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0);
|
||
// A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU).
|
||
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode =
|
||
effectcraft_color::BlendMode::Screen;
|
||
let (ctx, run) = run_of(&p, cid);
|
||
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none());
|
||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||
r.accel = Some(&g);
|
||
let gimg = r.comp_frame(cid, Tick::ZERO);
|
||
let (share, _) = off_share(&cpu.data, &gimg.data);
|
||
assert!(share <= 0.01, "blend-mode run: {:.3} %", share * 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_wireframes_match_cpu_exactly() {
|
||
let Some(g) = gpu() else { return };
|
||
let mut p = Project::default();
|
||
let c = Comp::new(200, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
|
||
let sid = p.add_item("S", Default::default(), None, ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: 200, height: 120, pixel_aspect: 1.0 }));
|
||
add(&mut p, cid, LayerSource::Solid { item: sid }, |_| {});
|
||
let s2 = p.add_item("W", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 70, height: 40, pixel_aspect: 1.0 }));
|
||
add(&mut p, cid, LayerSource::Solid { item: s2 }, |l| {
|
||
l.switches.quality = effectcraft_project::Quality::Wireframe;
|
||
set(l, "transform/rotation", Value::Scalar(23.0));
|
||
set(l, "transform/scale", Value::Vec3([130.0, 90.0, 100.0]));
|
||
});
|
||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||
r.accel = Some(&g);
|
||
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
|
||
let white = cpu.data.iter().filter(|q| **q == [1.0; 4]).count();
|
||
assert!(white > 100, "outline drawn: {white}");
|
||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||
assert!(share == 0.0, "{share} differ (max {max})");
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_dof_with_capped_radii() {
|
||
// Radii at the 48 px cap over most of the frame (8 blur levels).
|
||
let Some(g) = gpu() else { return };
|
||
let (mut p, cid) = dof_mb_project();
|
||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||
if let Some(pr) = l.props.prop_mut("cameraOptions/aperture") {
|
||
pr.value = Value::Scalar(1500.0);
|
||
}
|
||
l.switches.motion_blur = false;
|
||
}
|
||
compare_run(&p, cid, &g, "capped radii");
|
||
}
|
||
|
||
#[test]
|
||
fn gpu_environment_light_on_flat_extrusions_has_no_nan() {
|
||
// Bevelled text faces pointing straight up or down: atan2(0, 0) in the environment lookup
|
||
// (NaN on some GPUs) must give the CPU's 0.
|
||
let Some(g) = gpu() else { return };
|
||
let mut p = Project::default();
|
||
let mut c = Comp::new(480, 270, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||
c.renderer = R3::Advanced3D;
|
||
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
|
||
add(&mut p, cid, LayerSource::Text, |l| {
|
||
l.switches.three_d = true;
|
||
let doc =
|
||
effectcraft_keyframe::TextDoc { text: "EffectCraft".into(), size: 45.0, fill: [0.95, 0.75, 0.2, 1.0], apply_fill: true, ..Default::default() };
|
||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||
let mut next = 900_000u64;
|
||
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
|
||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(10.0));
|
||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||
set(l, "transform/position", Value::Vec3([240.0, 65.0, 50.0]));
|
||
set(l, "transform/rotationY", Value::Scalar(-15.0));
|
||
});
|
||
let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 }));
|
||
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
|
||
l.environment = true;
|
||
l.switches.three_d = true;
|
||
});
|
||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
|
||
let s = scene(&p, cid);
|
||
let t = g.raster_3d(&s).unwrap();
|
||
let nan = t.color.iter().filter(|c| c.iter().any(|x| !x.is_finite())).count();
|
||
assert_eq!(nan, 0, "non-finite pixels");
|
||
compare(&s, &g);
|
||
}
|
||
|
||
/// Advanced 3D runs whose layers take the 2D compositing path (blend modes, a 2D and a 3D track
|
||
/// matte, Preserve Transparency) and environment backgrounds composite on the GPU: against the
|
||
/// CPU compositor on the same (GPU) rasters they agree to float rounding; against the CPU
|
||
/// rasteriser within the raster tolerance.
|
||
#[test]
|
||
fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() {
|
||
let Some(g) = gpu() else { return };
|
||
for case in 0..10 {
|
||
let (mut p, cid) = scene_project();
|
||
if case >= 4 {
|
||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||
}
|
||
let classic = case >= 8;
|
||
let case = if classic { 3 } else { case % 4 };
|
||
if classic {
|
||
// Classic 3D: the sky on the GPU too, then the run's planes.
|
||
p.comp_mut(cid).unwrap().renderer = R3::Classic3D;
|
||
}
|
||
let bg = p.add_item("B", Default::default(), None, ItemKind::Solid(Solid { color: [0.1, 0.1, 0.15], width: 240, height: 160, pixel_aspect: 1.0 }));
|
||
let comp = p.comp(cid).unwrap().clone();
|
||
let l = build::layer(&mut p, &comp, "B", LayerSource::Solid { item: bg }, (240, 160), None);
|
||
p.comp_mut(cid).unwrap().layers.push(l);
|
||
// A varying environment image (so the sky's lookup shows; a CPU effect, so the frame's
|
||
// only readback is its own).
|
||
let mut next = p.next_id;
|
||
let spec = effectcraft_effects::find("ec.generate.fractal").unwrap();
|
||
let ramp = effectcraft_effects::instantiate(spec, &mut build::Ids(&mut next), spec.name, [32.0, 16.0]);
|
||
p.next_id = next;
|
||
if case == 3 {
|
||
// A 2D track matte layer above the run.
|
||
let sid = p.add_item("M", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 120, height: 90, pixel_aspect: 1.0 }));
|
||
add(&mut p, cid, LayerSource::Solid { item: sid }, |m| {
|
||
m.switches.video = false;
|
||
set(m, "transform/rotation", Value::Scalar(20.0));
|
||
});
|
||
}
|
||
let c = p.comp_mut(cid).unwrap();
|
||
let env = c.layers.iter_mut().find(|l| l.environment).unwrap();
|
||
env.props.sub_mut("effects").unwrap().children.push(ramp.into());
|
||
let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect();
|
||
let top = c.layers[0].id;
|
||
fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer {
|
||
c.layers.iter_mut().find(|l| l.id == id).unwrap()
|
||
}
|
||
match case {
|
||
0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen,
|
||
1 => {
|
||
// The card is a 3D track matte (luma) for the sphere; the torus multiplies.
|
||
let card = ids[0];
|
||
by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Multiply;
|
||
by_id(c, ids[2]).track_matte = Some(effectcraft_project::TrackMatte { layer: card, kind: effectcraft_project::MatteKind::Luma });
|
||
by_id(c, ids[0]).switches.video = false;
|
||
}
|
||
2 => {
|
||
// Preserve Transparency and an Overlay layer.
|
||
by_id(c, ids[0]).preserve_transparency = true;
|
||
by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Overlay;
|
||
}
|
||
_ => {
|
||
// An environment background behind the run, and the 2D track matte.
|
||
c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true;
|
||
by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha });
|
||
}
|
||
}
|
||
let (ctx, run) = run_of(&p, cid);
|
||
let r0 = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||
if case < 3 {
|
||
assert!(r0.split_adv_run(&ctx, &run, (240, 160)).is_some(), "case {case}: split");
|
||
}
|
||
let cpu = r0.comp_frame(cid, Tick::ZERO);
|
||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||
r.accel = Some(&g);
|
||
// The CPU compositor on the GPU's rasters.
|
||
let hybrid = r.comp_frame_cpu(cid, Tick::ZERO);
|
||
let before = g.context().transfer_stats().readbacks;
|
||
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
|
||
// On the device end to end: the frame's own readback only (no CPU fallback).
|
||
assert_eq!(g.context().transfer_stats().readbacks - before, 1, "case {case}: readbacks");
|
||
let (share, max) = off_share(&hybrid.data, &gimg.data);
|
||
eprintln!("split case {case}: gpu walk vs cpu compositing {:.4} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||
assert!(share <= 0.001, "case {case}: {:.3} % of pixels differ (max {max})", share * 100.0);
|
||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||
eprintln!("split case {case}: gpu vs cpu {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||
assert!(share <= 0.015, "case {case}: {:.3} % of pixels differ from the CPU", share * 100.0);
|
||
}
|
||
}
|
||
|
||
/// An adapter whose render targets don't qualify (`R32Float` was not renderable on GL's llvmpipe;
|
||
/// the pipelines failed validation, which release builds only log): Advanced 3D renders on the
|
||
/// CPU, the GPU compositor's frame matches the CPU's, and no render pipeline is built (a
|
||
/// validation error panics under test).
|
||
#[test]
|
||
fn adapters_that_cannot_rasterise_render_advanced_3d_on_the_cpu() {
|
||
crate::tests::hold_gpu_lock();
|
||
let Some(mut ctx) = crate::context::GpuContext::headless() else { return };
|
||
ctx.adv3d_raster = false;
|
||
let g = Gpu::from_context(ctx);
|
||
let (p, cid) = scene_project();
|
||
let s = scene(&p, cid);
|
||
assert!(g.raster_3d(&s).is_none(), "no GPU raster");
|
||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||
r.accel = Some(&g);
|
||
let gimg = r.comp_frame(cid, Tick::ZERO);
|
||
let worst = cpu.data.iter().zip(&gimg.data).flat_map(|(a, b)| (0..4).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max);
|
||
assert!(worst <= 2.0 / 255.0, "GPU compositor with CPU Advanced 3D vs CPU: worst {worst}");
|
||
}
|