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.
223 lines
9.8 KiB
Rust
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");
|
|
}
|