Files
oak-editor/crates/oak-render/tests/ofxmisc_blur.rs
T
Mike-Solar 48e99e56b7 render: the M2 GPU zero-copy pipeline — wgpu 29, shared gpui device, GPU color LUTs
docs/zh/plans/render-pipeline-threads.md M2: the graph's textures stay
on the GPU from evaluation through presentation, and presentation runs
on the UI's own wgpu device.

- wgpu 25 -> 29 (naga 29) across the engine, unifying it with
  gpui_wgpu so engine textures are directly sampleable by the presenter
  (a single wgpu remains in the lockfile).
- GpuContext::adopt/install_shared: the app registers the window's
  device at startup and the render thread renders on it;
  texture_handle hands the raw Arc<wgpu::Texture> to
  SurfaceSource::Texture - zero-copy present on Linux/FreeBSD. The
  shared slot replaces an engine context that has not touched the GPU
  yet (startup-order guard) and refuses once it has.
- Texture::Gpu shares a GpuLease so clones release the registry token
  exactly once; the compositor, transitions and adjustment sweeps keep
  GPU textures end to end (no per-clip readbacks; GPU clears for
  black/generated frames).
- Color management stays on the GPU: the output node + display ICC
  chain is baked into a 65^3 3D LUT with the exact CPU reference and
  applied by the present WGSL pass (manual trilinear);
  ColorTransformJob bakes its OCIO processor the same way. Neither
  path skips color management.
- The explicit readback boundaries accept GPU textures: export
  encoder, CLI, worker shm, disk cache; CPU OpenFX already read back.
- M5 dependency: the YUV->RGB GPU pass (BT.601/709/2020 x
  limited/full) matches colormath::yuv444p16_to_rgb_f32.
- Acceptance: gpu_transfer_counters; single-clip and layered
  (multi-track + transition + adjustment) playback tests assert zero
  GPU->CPU readbacks, and the app test asserts adopted-device present
  is zero-copy. GPU tests hard-fail when OAK_REQUIRE_GPU is set (CI
  lavapipe) instead of skipping silently.
2026-09-12 20:52:17 +08:00

223 lines
9.8 KiB
Rust

//! GPU pixel tests for the Tier-1 blur/sharpen nodes (skip without GPU).
use oak_core::texture::Texture;
use oak_core::{PixelFormat, Rational};
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
fn gpu() -> bool { oak_core::backend::shared_gpu_or_skip("an OFX-Misc GPU test").is_some() }
fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
Texture::wrap_frame(f)
}
fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
let stride = frame.linesize_bytes() as usize;
let at = y * stride + x * 16;
let mut out = [0f32; 4];
for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
out
}
fn eval_node_row(type_id: &str, inputs: NodeValueRow, frame_size: Option<(i32, i32)>) -> oak_core::texture::Frame {
use oak_node::traverser::RenderHooks;
let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
let mut table = NodeValueTable::default();
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
let mut hooks = oak_render::eval::RenderEvalHooks::new();
hooks.frame_size = frame_size;
hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
let tex = unsafe { oak_node::handle::get_checked::<Texture>(handle) }.expect("resolved texture");
assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
tex.to_frame().expect("readback")
}
const DIRBLUR: &str = "org.olivevideoeditor.Olive.dirblur";
const SHARPEN: &str = "org.olivevideoeditor.Olive.sharpen";
/// A 16x16 F32 frame painted pixel by pixel from `paint(x, y)`.
fn painted_frame(paint: impl Fn(usize, usize) -> [f32; 4]) -> Texture {
let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), (16, 16), PixelFormat::F32).unwrap();
let stride = f.linesize_bytes() as usize;
for y in 0..16 {
for x in 0..16 {
let at = y * stride + x * 16;
for (c, v) in paint(x, y).iter().enumerate() {
f.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
}
}
Texture::wrap_frame(f)
}
/// A row carrying the effect input plus any explicitly set float inputs.
fn row_with(texture: Texture, scalars: &[(&str, f64)]) -> NodeValueRow {
let mut row = NodeValueRow::new();
row.insert("tex_in".to_string(), texture_value(texture));
for (name, v) in scalars {
row.insert((*name).to_string(), NodeValue::Float(*v));
}
row
}
/// Assert every channel of `px` is within `tol` of `want`.
fn assert_close(px: [f32; 4], want: [f32; 4], tol: f32, what: &str) {
for c in 0..4 {
assert!(
(px[c] - want[c]).abs() <= tol,
"{what}: channel {c}: got {px:?}, want {want:?} (tol {tol})"
);
}
}
/// Zero amount (the input default) collapses the tap spacing to zero, so
/// the directional blur is an exact identity anchored to the sampled
/// coordinate; it still renders on the GPU.
#[test]
fn dirblur_zero_amount_is_identity() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let src = painted_frame(|x, y| [x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0]);
let out = eval_node_row(DIRBLUR, row_with(src, &[]), None);
for (x, y) in [(0, 0), (5, 3), (8, 8), (14, 12), (15, 15)] {
assert_close(
pixel_at(&out, x, y),
[x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0],
1e-3,
&format!("dirblur zero amount ({x},{y})"),
);
}
}
/// A one-pixel-wide white vertical line smears along +x at angle 0:
/// pixels either side pick up the line, the line itself dims, and
/// nothing leaks along y.
#[test]
fn dirblur_smears_horizontally_without_vertical_spread() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let src = painted_frame(|x, y| {
if x == 8 && (4..12).contains(&y) { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }
});
let out = eval_node_row(
DIRBLUR,
row_with(src, &[("amount_in", 2.0), ("angle_in", 0.0)]),
None,
);
let spread_l = pixel_at(&out, 6, 8)[0];
let spread_r = pixel_at(&out, 10, 8)[0];
assert!((0.05..0.3).contains(&spread_l), "pixel left of the line picks up the smear: {spread_l}");
assert!((spread_l - spread_r).abs() < 0.02, "smear is symmetric about the line: {spread_l} vs {spread_r}");
// The line dims, but not to nothing: the 16 taps sit 2*2/15 px apart,
// so they land at 8 + t*4/15 for t = -7.5..7.5 and the sampler's
// 1 px-wide linear reconstruction gives them 13/15, 9/15, 5/15 and
// 1/15 of the line each, twice over, leaving the center pixel at
// 2*(13+9+5+1)/(15*16) = 7/30.
let on_line = pixel_at(&out, 8, 8)[0];
assert!(
(on_line - 7.0 / 30.0).abs() < 0.02,
"a one-pixel line dims to ~7/30 of its brightness: {on_line}"
);
// The smear redistributes the line's brightness without adding or
// losing any: the linear-filter triangle has unit area, so the 16
// output pixels (each the average of 16 unit-spaced samples) still
// sum to the whole line. This holds whatever the filter mode.
let row_energy: f32 = (0..16).map(|x| pixel_at(&out, x, 8)[0]).sum();
assert!(
(row_energy - 1.0).abs() < 0.02,
"the row keeps the line's total brightness: {row_energy}"
);
assert!(pixel_at(&out, 5, 8)[0] < 0.01, "two pixels out stays black");
assert!(pixel_at(&out, 8, 3)[0] < 0.01, "no spread above the line");
assert!(pixel_at(&out, 8, 12)[0] < 0.01, "no spread below the line");
let alpha = pixel_at(&out, 6, 8)[3];
assert!((alpha - 1.0).abs() < 1e-3, "alpha passes through: {alpha}");
}
/// At angle 90 the smear runs along +y: the same one-pixel line laid
/// horizontally bleeds into the rows above and below it, and not along
/// its own axis.
#[test]
fn dirblur_angle_90_smears_vertically() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let src = painted_frame(|x, y| {
if y == 8 && (4..12).contains(&x) { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }
});
let out = eval_node_row(
DIRBLUR,
row_with(src, &[("amount_in", 2.0), ("angle_in", 90.0)]),
None,
);
let above = pixel_at(&out, 8, 6)[0];
let below = pixel_at(&out, 8, 10)[0];
assert!((0.05..0.3).contains(&above), "row above the line picks up the smear: {above}");
assert!((above - below).abs() < 0.02, "smear is symmetric about the line: {above} vs {below}");
// Same arithmetic as the horizontal case, rotated: 7/30 of the line.
let on_line = pixel_at(&out, 8, 8)[0];
assert!(
(on_line - 7.0 / 30.0).abs() < 0.02,
"a one-pixel line dims to ~7/30 of its brightness: {on_line}"
);
let column_energy: f32 = (0..16).map(|y| pixel_at(&out, 8, y)[0]).sum();
assert!(
(column_energy - 1.0).abs() < 0.02,
"the column keeps the line's total brightness: {column_energy}"
);
assert!(pixel_at(&out, 8, 5)[0] < 0.01, "two rows out stays black");
assert!(pixel_at(&out, 3, 6)[0] < 0.01, "no spread left of the line");
assert!(pixel_at(&out, 1, 8)[0] < 0.01, "no spread along the line's axis");
}
/// A flat region is untouched by the unsharp mask whatever the amount
/// (the default 1.0 included), and an explicit zero amount is an exact
/// identity on a gradient.
#[test]
fn sharpen_flat_and_zero_amount_pass_through() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let rgba = [0.4, 0.6, 0.8, 1.0];
let out = eval_node_row(SHARPEN, row_with(filled_frame((16, 16), rgba), &[]), None);
assert_close(pixel_at(&out, 8, 8), rgba, 1e-3, "sharpen default amount on a flat frame");
let src = painted_frame(|x, y| [x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0]);
let out = eval_node_row(SHARPEN, row_with(src, &[("amount_in", 0.0)]), None);
for (x, y) in [(0, 0), (5, 3), (8, 8), (14, 12), (15, 15)] {
assert_close(
pixel_at(&out, x, y),
[x as f32 / 15.0, y as f32 / 15.0, 0.25, 1.0],
1e-3,
&format!("sharpen zero amount ({x},{y})"),
);
}
}
/// At a black/white step the 3x3 box blur is 2/3 white on the bright
/// side (overshoot to 4/3) and 1/3 white on the dark side (undershoot to
/// -1/3); flat columns pass through and the result is not clamped.
#[test]
fn sharpen_overshoots_and_undershoots_a_step_edge() {
if !gpu() { eprintln!("no adapter; skipping"); return; }
let src = painted_frame(|x, _| {
if x < 8 { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }
});
let out = eval_node_row(SHARPEN, row_with(src, &[("amount_in", 1.0)]), None);
let bright = pixel_at(&out, 8, 8);
assert!((bright[0] - 4.0 / 3.0).abs() < 0.01, "overshoot on the bright side: {bright:?}");
assert!(bright[0] > 1.0, "overshoot is not clamped: {bright:?}");
assert!((bright[3] - 1.0).abs() < 1e-3, "alpha passes through: {bright:?}");
let dark = pixel_at(&out, 7, 8);
assert!((dark[0] + 1.0 / 3.0).abs() < 0.01, "undershoot on the dark side: {dark:?}");
assert!(dark[0] < 0.0, "undershoot is not clamped: {dark:?}");
assert!((dark[3] - 1.0).abs() < 1e-3, "alpha passes through: {dark:?}");
assert_close(pixel_at(&out, 6, 8), [0.0, 0.0, 0.0, 1.0], 1e-3, "flat black column");
assert_close(pixel_at(&out, 9, 8), [1.0, 1.0, 1.0, 1.0], 1e-3, "flat white column");
}