From d028a45ffa4d9e683bbdf03f5ab22cdb5ead3ef1 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 10 Sep 2026 18:13:05 +0800 Subject: [PATCH] nodes: pivot transform rotation/scale around the frame center The transform shader sampled in a top-left-origin pixel space while Olive's transform semantics (and every other node) are center-origin: rotation swung the image around the top-left corner, pushing it partly off-frame - reading exactly like an unwanted zoom. Match the C++ transform.vert projection: position (0,0) is the frame center and rotation/scale pivot around the anchor, so rotation and scale stay independent user controls. GPU tests pin the 90-degree landing spot (no smearing) and the 2x scale centroid (stays centered). --- .../src/nodes/transformdistortnode.rs | 14 ++-- crates/oak-render/src/eval.rs | 80 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/oak-node/src/nodes/transformdistortnode.rs b/crates/oak-node/src/nodes/transformdistortnode.rs index e903f5944..9380a8f9f 100644 --- a/crates/oak-node/src/nodes/transformdistortnode.rs +++ b/crates/oak-node/src/nodes/transformdistortnode.rs @@ -24,9 +24,12 @@ /// The transform fragment shader: samples the input through the inverse /// of the node's pixel-space transform (`transform_in` carries the /// CPU-inverted matrix; `resolution_in` is auto-filled by the runner). -/// The C++ path transforms vertices (`ove_mvpmat` in transform.vert); -/// an affine transform is equivalently applied fragment-side by -/// inverse-mapping the sample position — the fixed fullscreen vertex +/// Pixel space is CENTER-origin (C++ `transform.vert` transforms the +/// quad in the frame-centered projection): position (0,0) is the frame +/// center and rotation/scale pivot around the anchor — NOT around the +/// top-left corner, which is what a top-left-origin pixel space would +/// pivot on (a corner pivot swings the image off-frame as it rotates, +/// reading exactly like an unwanted zoom). The fixed fullscreen vertex /// stage stays unchanged. const TRANSFORM_FRAG: &str = r#"uniform sampler2D tex_in; uniform mat4 transform_in; @@ -36,8 +39,9 @@ in vec2 ove_texcoord; out vec4 frag_color; void main(void) { - vec2 px = ove_texcoord * resolution_in; - vec2 src = (transform_in * vec4(px, 0.0, 1.0)).xy; + vec2 half_res = resolution_in * 0.5; + vec2 px = ove_texcoord * resolution_in - half_res; + vec2 src = (transform_in * vec4(px, 0.0, 1.0)).xy + half_res; frag_color = texture(tex_in, src / resolution_in); } "#; diff --git a/crates/oak-render/src/eval.rs b/crates/oak-render/src/eval.rs index e368f376f..1b6aa5ef7 100644 --- a/crates/oak-render/src/eval.rs +++ b/crates/oak-render/src/eval.rs @@ -2935,6 +2935,86 @@ mod tests { assert_eq!(pixel_at(&out, 0, 0), [0.0, 0.0, 0.0, 0.0]); } + /// Transform rotation pivots around the FRAME CENTER (the C++ + /// center-origin pixel space), not the top-left corner: a 90° turn + /// moves a pixel sitting 2px right of center to 2px below center, + /// with no scaling or smearing (the turn is lossless). + #[test] + fn gpu_transform_rotates_around_the_frame_center() { + if oak_core::backend::GpuContext::shared().is_none() { + eprintln!("no adapter; skipping"); + return; + } + // 8x8 black frame with one white pixel at (6, 4) — center+(2, 0). + let mut frame = generate_frame(Rational::new(0, 1), (8, 8), PixelFormat::F32).unwrap(); + let at = (4 * 8 + 6) * 16; + for (c, v) in [1.0f32, 1.0, 1.0, 1.0].iter().enumerate() { + frame.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes()); + } + let mut inputs = NodeValueRow::new(); + inputs.insert("tex_in".into(), texture_value(Texture::wrap_frame(frame))); + inputs.insert("rot_in".into(), NodeValue::Float(90.0)); + + let out = eval_node_row("org.olivevideoeditor.Olive.transform", inputs, None); + assert_eq!(pixel_at(&out, 6, 4), [0.0, 0.0, 0.0, 0.0], "source spot vacated"); + assert_eq!( + pixel_at(&out, 3, 6), + [1.0, 1.0, 1.0, 1.0], + "90° around (4,4) maps texel center (6.5,4.5) -> (3.5,6.5)" + ); + let lit = out + .data + .chunks_exact(16) + .filter(|px| f32::from_le_bytes(px[12..16].try_into().unwrap()) > 0.01) + .count(); + assert_eq!(lit, 1, "a pure rotation neither scales nor smears: {lit} lit pixels"); + } + + /// Transform scale pivots around the frame center too: a center 2x2 + /// block at 2x uniform scale grows into the surrounding 4x4 (a + /// corner pivot would drag it toward the bottom-right instead). + #[test] + fn gpu_transform_scales_around_the_frame_center() { + if oak_core::backend::GpuContext::shared().is_none() { + eprintln!("no adapter; skipping"); + return; + } + // 8x8 black frame with a white 2x2 block at texels (3..4, 3..4). + let mut frame = generate_frame(Rational::new(0, 1), (8, 8), PixelFormat::F32).unwrap(); + for (x, y) in [(3usize, 3usize), (4, 3), (3, 4), (4, 4)] { + let at = (y * 8 + x) * 16; + for (c, v) in [1.0f32, 1.0, 1.0, 1.0].iter().enumerate() { + frame.data[at + c * 4..at + c * 4 + 4].copy_from_slice(&v.to_le_bytes()); + } + } + let mut inputs = NodeValueRow::new(); + inputs.insert("tex_in".into(), texture_value(Texture::wrap_frame(frame))); + inputs.insert("scale_in".into(), NodeValue::Vec2([2.0, 2.0])); + + let out = eval_node_row("org.olivevideoeditor.Olive.transform", inputs, None); + // Pivot check via the alpha distribution: the block [3,5] scaled + // 2x around (4,4) grows symmetrically to [2,6] — the centroid + // stays at the frame center. A corner pivot would drag the block + // to [6,10], shifting the centroid off-center and clipping the + // block against the frame edge. + let mut total = 0.0f32; + let mut cx = 0.0f32; + let mut cy = 0.0f32; + for y in 0..8usize { + for x in 0..8usize { + let a = pixel_at(&out, x, y)[3]; + total += a; + cx += (x as f32 + 0.5) * a; + cy += (y as f32 + 0.5) * a; + } + } + let (cx, cy) = (cx / total, cy / total); + assert!( + (cx - 4.0).abs() < 0.2 && (cy - 4.0).abs() < 0.2, + "the block grows around the frame center, centroid ({cx}, {cy})" + ); + } + /// Shape generator over the real GPU path: a centered 8x8 rectangle /// on a 16x16 frame fills exactly the middle block (pixel centers /// with texcoord in [0.25, 0.75)), everything outside stays