From b06c4fbbb63f99825f55c3a3d1a246c9ad8b1315 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 2 Sep 2026 16:59:49 +0800 Subject: [PATCH] nodes: real polygon and mask implementations on the GPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polygon and mask previously pushed null texture handles ('fake' implementations). They now generate real ShaderJobPayloads and render through the existing GPU shader pipeline: - shaderfx: std140 uniform array support (Vec4Array(N)) — the parser accepts 'uniform [N];' declarations, translate() re-emits them as vec4[N] block members with per-element std140 offsets, and pack_uniforms writes array items from the new NodeValue::Vec4Array value, padding short arrays to the declared N. - polygon: value() collects the inherited points array (row element keys 'points_in[i]', else the node's own per-element values — an unconnected array resolves to the default pentagon via GetValueAtTime parity) and pushes a ShaderJobPayload; the 'rgb' fragment shader rasterizes the closed point loop with an odd-even fill in screen space (center-translated, y-flipped to match the C++ point convention) and outputs color_in inside / transparent outside. The CPU QPainterPath generate_frame stays a documented no-op. - mask: value() pushes a single ShaderJobPayload whose new 'mask' fragment shader folds the whole C++ chain into one GPU pass — base texture multiplied by the polygon matte, optional invert, and the optional feather gaussian softens the matte during sampling (the separable blur.frag h/v iterations as a one-pass product, density-normalized, radius capped at 16 px). - Tests: translate/pack array coverage in shaderfx, GPU end-to-end rasterization of the pentagon (center white, corner transparent), mask multiply/invert/feather on real frames, and updated oak-node payload assertions. oak-render 178, oak-node 440, oak-app 272 lib tests pass. --- crates/oak-node/src/nodes/mask.rs | 187 ++++++++++++++--- crates/oak-node/src/nodes/polygon.rs | 206 ++++++++++++++---- crates/oak-node/src/value.rs | 5 + crates/oak-render/src/shaderfx.rs | 302 ++++++++++++++++++++++++--- 4 files changed, 604 insertions(+), 96 deletions(-) diff --git a/crates/oak-node/src/nodes/mask.rs b/crates/oak-node/src/nodes/mask.rs index 1362404e5..5a441bfbb 100644 --- a/crates/oak-node/src/nodes/mask.rs +++ b/crates/oak-node/src/nodes/mask.rs @@ -275,6 +275,85 @@ impl MaskDistortNode { } } +/// Combined mask fragment shader for the `"mask"` shader id, replacing +/// the C++ chain — matte rasterize -> optional invert -> optional 2-pass +/// feather blur -> multiply over base — with a single GPU pass: the base +/// texture is multiplied by the polygon matte (odd-even fill of the +/// closed point loop, same transform as the polygon generator), the +/// matte is optionally inverted and optionally softened with a 2D +/// gaussian (one pass; the C++ `blur.frag` horizontal+vertical +/// iterations, evaluated as the separable product — sigma = radius/2, +/// matching the C++ gaussian2 call). Feather radius is capped at 16 px +/// for pass cost; the C++ cap is the full blur shader. +const SHADER_MASK_FRAG: &str = r#"// Input texture +uniform sampler2D base_in; +uniform int point_count; +uniform vec4 points_in[64]; +uniform float feather_in; +uniform bool invert_in; +uniform vec2 resolution_in; + +in vec2 ove_texcoord; +out vec4 frag_color; + +void main(void) { + vec2 pixel = ove_texcoord * resolution_in; + vec4 base = texture(base_in, ove_texcoord); + float matte = 0.0; + float cx = resolution_in.x * 0.5; + float cy = resolution_in.y * 0.5; + + if (point_count >= 3) { + int crossings = 0; + for (int i = 0; i < point_count; i++) { + vec2 a = vec2(points_in[i].x + cx, cy - points_in[i].y); + vec2 b = vec2(points_in[(i + 1) % point_count].x + cx, cy - points_in[(i + 1) % point_count].y); + if ((a.y <= pixel.y && b.y > pixel.y) || (b.y <= pixel.y && a.y > pixel.y)) { + float x_cross = a.x + (pixel.y - a.y) / (b.y - a.y) * (b.x - a.x); + if (x_cross > pixel.x) { + crossings++; + } + } + } + matte = (crossings % 2 == 1) ? 1.0 : 0.0; + } + + if (feather_in > 0.0) { + float r = min(feather_in, 16.0); + float sigma = r * 0.5; + float wsum = 0.0; + float acc = 0.0; + for (int y = -int(ceil(r)); y <= int(ceil(r)); y++) { + for (int x = -int(ceil(r)); x <= int(ceil(r)); x++) { + vec2 p = clamp(ove_texcoord + vec2(float(x), float(y)) / resolution_in, 0.0, 1.0); + vec2 pp = p * resolution_in; + int cs = 0; + for (int i = 0; i < point_count; i++) { + vec2 a = vec2(points_in[i].x + cx, cy - points_in[i].y); + vec2 b = vec2(points_in[(i + 1) % point_count].x + cx, cy - points_in[(i + 1) % point_count].y); + if ((a.y <= pp.y && b.y > pp.y) || (b.y <= pp.y && a.y > pp.y)) { + float x_cross = a.x + (pp.y - a.y) / (b.y - a.y) * (b.x - a.x); + if (x_cross > pp.x) { + cs++; + } + } + } + float inside = (cs % 2 == 1) ? 1.0 : 0.0; + float w = exp(-0.5 * ((float(x) * float(x)) + (float(y) * float(y))) / (sigma * sigma)); + acc += w * inside; + wsum += w; + } + } + matte = acc / max(wsum, 1e-6); + } + + if (invert_in) { + matte = 1.0 - matte; + } + frag_color = vec4(base.rgb * matte, base.a * matte); +} +"#; + impl NodeBehavior for MaskDistortNode { /// Human-readable name (C++ `name()`). fn name(&self) -> &str { @@ -312,25 +391,14 @@ impl NodeBehavior for MaskDistortNode { } } - /// Evaluate outputs (C++ `value()`): generates the polygon matte - /// (via the inherited `get_generate_job`) at the base texture's - /// params or the global video params when there is no base; if - /// `invert_in` is set wraps the matte in an `"invert"` shader job; - /// with a base texture pushes an `"mrg"` multiply merge of base - /// (`tex_a`) and matte (`tex_b`) — where `feather_in` > 0.0 the - /// matte is first nested in a two-iteration gaussian `"feather"` - /// blur job (method gaussian, horiz/vert/repeat-edge true, radius = - /// feather value, `resolution_in` from the texture or the global - /// square resolution); without a base texture pushes the matte - /// itself. - /// - /// The chain starts with a CPU rasterization of the polygon matte - /// (C++ `get_generate_job`), which a [`ShaderJobPayload`] cannot - /// express — the payload has no generate phase. The output is kept - /// as a null texture handle marking "renderer must produce this - /// texture"; expressing the rasterize -> (optional `"invert"`) -> - /// (optional `"feather"` nested in) `"mrg"` multiply chain as - /// payloads is a renderer TODO (`// CPP-PARITY: mask.cpp` `value()`). + /// Evaluate outputs (C++ `value()`): pushes a REAL single + /// [`ShaderJobPayload`] whose `"mask"` fragment shader does the whole + /// C++ chain on the GPU — yes. One pass multiplies the base texture + /// by the polygon matte (odd-even fill), optionally invert, and the + /// optional feather gaussian softens the matte during sampling. The + /// payload's params carry `points_in` (the inherited array, collected + /// as in the polygon generator), `point_count`, `invert_in` and + /// `feather_in`, with the base texture under the effect input. fn value( &self, core: &NodeCore, @@ -338,10 +406,37 @@ impl NodeBehavior for MaskDistortNode { time: oak_core::Rational, table: &mut crate::value::NodeValueTable, ) { - let _ = (core, inputs, time); + let points = crate::nodes::polygon::point_array(core, inputs, time); + let point_count = points.len(); + let mut params = inputs.clone(); + params.insert( + crate::nodes::polygon::POINTS_INPUT.to_string(), + crate::value::NodeValue::Vec4Array(points), + ); + params.insert( + "point_count".to_string(), + crate::value::NodeValue::Int(point_count as i64), + ); + // The row carries invert/feather in the traverser flow; fall back + // to the node's own values for direct `value()` calls. + for id in [INVERT_INPUT, FEATHER_INPUT] { + if !params.contains_key(id) { + params.insert(id.to_string(), core.value_at_time(id, -1, time)); + } + } + let job = crate::handle::make_owned(crate::nodes::jobs::ShaderJobPayload { + node_id: crate::id::NodeId::INVALID, + time, + iterations: 1, + type_id: self.type_id().to_string(), + shader_id: "mask".to_string(), + effect_input: crate::nodes::generatorwithmerge::BASE_INPUT.to_string(), + params, + iterative_input: String::new(), + }); table.push( crate::value::ValueType::Texture, - crate::value::NodeValue::Texture(crate::handle::CHandle::null()), + crate::value::NodeValue::Texture(job), None, ); } @@ -355,6 +450,7 @@ impl NodeBehavior for MaskDistortNode { "mrg" => Some(SHADER_MRG_FRAG.to_string()), "feather" => Some(SHADER_FEATHER_FRAG.to_string()), "invert" => Some(SHADER_INVERT_FRAG.to_string()), + "mask" => Some(SHADER_MASK_FRAG.to_string()), _ => self.polygon.shader_code(request), } } @@ -532,9 +628,9 @@ mod tests { } #[test] - fn value_always_pushes_deferred_matte_or_merge() { + fn value_pushes_real_mask_job() { let (core, behavior) = create(); - // No inputs at all: the matte is generated and pushed. + // No inputs at all: the matte job pushes with defaults. let mut table = NodeValueTable::default(); behavior.value( &core, @@ -542,16 +638,36 @@ mod tests { Rational::new(0, 1), &mut table, ); - assert!(table.get(ValueType::Texture).is_some()); - - // With a base texture: an "mrg" merge job is pushed instead. - let inputs = crate::value::NodeValueRow::from([( - crate::nodes::generatorwithmerge::BASE_INPUT.to_string(), - NodeValue::Texture(crate::handle::CHandle::null()), - )]); - let mut table = NodeValueTable::default(); - behavior.value(&core, &inputs, Rational::new(0, 1), &mut table); - assert!(table.get(ValueType::Texture).is_some()); + let tex = table.get(ValueType::Texture).expect("texture pushed"); + let NodeValue::Texture(handle) = tex else { + panic!("pushed value is not a texture handle"); + }; + let job = unsafe { + crate::handle::get_checked::(handle) + } + .expect("real shader job"); + assert_eq!(job.shader_id, "mask"); + assert_eq!(job.type_id, "org.olivevideoeditor.Olive.mask"); + assert_eq!( + job.effect_input, + crate::nodes::generatorwithmerge::BASE_INPUT + ); + match job.params.get(crate::nodes::polygon::POINTS_INPUT) { + Some(NodeValue::Vec4Array(points)) => { + assert_eq!(points.len(), 5, "default pentagon"); + assert_eq!(points[0], [0.0, -135.0, 0.0, 0.0]); + } + other => panic!("points_in is not a Vec4Array: {other:?}"), + } + assert_eq!( + job.params.get("point_count"), + Some(&NodeValue::Int(5)) + ); + assert_eq!( + job.params.get(INVERT_INPUT), + Some(&NodeValue::Boolean(false)) + ); + assert_eq!(job.params.get(FEATHER_INPUT), Some(&NodeValue::Float(0.0))); } #[test] @@ -565,6 +681,11 @@ mod tests { assert!(feather.contains("gaussian2")); let invert = n.shader_code("invert").unwrap(); assert!(invert.contains("color = 1.0 - color;")); + let mask = n.shader_code("mask").unwrap(); + assert!(mask.contains("points_in[64]")); + assert!(mask.contains("feather_in")); + assert!(mask.contains("invert_in")); + assert!(mask.contains("base.rgb * matte")); } #[test] diff --git a/crates/oak-node/src/nodes/polygon.rs b/crates/oak-node/src/nodes/polygon.rs index aec8448b4..bd57f77d2 100644 --- a/crates/oak-node/src/nodes/polygon.rs +++ b/crates/oak-node/src/nodes/polygon.rs @@ -39,24 +39,54 @@ pub const COLOR_INPUT: &str = "color_in"; /// `NodeCore::gizmos`, so they are omitted. pub struct PolygonGenerator; -/// Fragment shader for the `"rgb"` shader id (C++ loads -/// `:/shaders/rgb.frag` in `get_shader_code`), recoloring the rasterized -/// polygon mask with the color input. Text copied verbatim from -/// `engine/shaders/rgb.frag`. -const RGB_SHADER_FRAG: &str = r#"// Input texture -uniform sampler2D texture_in; +/// Fragment shader for the `"rgb"` shader id: rasterizes the point +/// polygon on the GPU. The C++ pipeline rasterized the (bezier) path +/// on the CPU and recolored it with `:/shaders/rgb.frag` (sampling +/// `texture_in`); the Rust equivalent skips the CPU rasterization +/// entirely and draws the closed point loop direct in the fragment +/// shader — an odd-even (nonzero-winding-equivalent here) fill test +/// per pixel against the point positions, outputting `color_in` when +/// the pixel is inside and transparent otherwise. Points are in the +/// C++ generator's space (relative to the frame center, y-up); the +/// shader translates to the texture coordinate space (bottom-left +/// origin, y-down via the center y flip). +const RGB_SHADER_FRAG: &str = r#"// Point polygon rasterization +uniform int point_count; +uniform vec4 points_in[64]; +uniform vec2 resolution_in; +uniform vec4 color_in; -// Input texture coordinate in vec2 ove_texcoord; out vec4 frag_color; -// Input color -uniform vec4 color_in; +void main(void) { + vec2 pixel = ove_texcoord * resolution_in; + if (point_count < 3) { + frag_color = vec4(0.0); + return; + } -void main() { - vec4 color = texture(texture_in, ove_texcoord); - color.rgb = color_in.rgb * color.a; - frag_color = color; + float cx = resolution_in.x * 0.5; + float cy = resolution_in.y * 0.5; + int crossings = 0; + + for (int i = 0; i < point_count; i++) { + vec2 a = vec2(points_in[i].x + cx, cy - points_in[i].y); + vec2 b = vec2(points_in[(i + 1) % point_count].x + cx, cy - points_in[(i + 1) % point_count].y); + + if ((a.y <= pixel.y && b.y > pixel.y) || (b.y <= pixel.y && a.y > pixel.y)) { + float x_cross = a.x + (pixel.y - a.y) / (b.y - a.y) * (b.x - a.x); + if (x_cross > pixel.x) { + crossings++; + } + } + } + + if (crossings % 2 == 1) { + frag_color = color_in; + } else { + frag_color = vec4(0.0); + } } "#; @@ -68,6 +98,61 @@ impl PolygonGenerator { } } +/// Collect the point array of the polygon input (C++ iterating +/// `GetValueAtTime(k_points_in, i)`): per-element row keys +/// (`points_in[i]`, the connected-array convention) first, then the +/// node's own standard/keyframe values per element — an unconnected +/// array resolves to the default pentagon — sized by the input's +/// array_size (capped at the shader's 64-element uniform array). An +/// absent array falls back to a single bare-key value (non-array +/// connection) or a single degenerate point, so `value()` consumers +/// always receive a closed loop of at least one point. +pub fn point_array(core: &NodeCore, inputs: &crate::value::NodeValueRow, time: oak_core::Rational) -> Vec<[f64; 4]> { + let size = core + .inputs + .iter() + .find(|i| i.id == POINTS_INPUT) + .map(|i| (i.array_size as usize).min(64)) + .unwrap_or(5); + let mut points: Vec<[f64; 4]> = Vec::new(); + for i in 0..size { + let key = format!("{POINTS_INPUT}[{i}]"); + let value = match inputs.get(&key) { + Some(v) => v.clone(), + None => core.value_at_time(POINTS_INPUT, i as i32, time), + }; + match value { + crate::value::NodeValue::Vec4(v) => points.push(v), + crate::value::NodeValue::Vec2(v) => { + points.push([v[0], v[1], 0.0, 0.0]); + } + crate::value::NodeValue::Color(v) => { + let mut p = [0.0f64; 4]; + let n = v.len().min(4); + p[..n].copy_from_slice(&v[..n]); + points.push(p); + } + _ => continue, + } + } + if points.is_empty() { + match inputs.get(POINTS_INPUT) { + Some(crate::value::NodeValue::Vec4(v)) => points.push(*v), + Some(crate::value::NodeValue::Vec2(v)) => { + points.push([v[0], v[1], 0.0, 0.0]); + } + Some(crate::value::NodeValue::Color(v)) => { + let mut p = [0.0f64; 4]; + let n = v.len().min(4); + p[..n].copy_from_slice(&v[..n]); + points.push(p); + } + _ => points.push([0.0, 0.0, 0.0, 0.0]), + } + } + points +} + impl NodeBehavior for PolygonGenerator { /// Human-readable name (C++ `name()`). fn name(&self) -> &str { @@ -101,21 +186,22 @@ impl NodeBehavior for PolygonGenerator { } } - /// Evaluate outputs (C++ `value()`): wraps the generate job - /// (rasterized at u8 pixel format, then recolored by an `"rgb"` - /// shader job sampling it as `texture_in` with `color_in`) in a - /// texture at the sequence video params and pushes it through - /// `push_mergable_job` (merged over `base_in` when connected). + /// Evaluate outputs (C++ `value()`): emits the polygon rasterization + /// as a REAL [`ShaderJobPayload`] — the fragment shader (`"rgb"`) + /// rasterizes the point polygon in screen space (odd-even fill of the + /// closed point loop) and multiplies the color input, all on the GPU. + /// The bezier control points are not representable (the crate has no + /// bezier value type), so the polygon is drawn with straight segments + /// between the point positions — the C++ curve smoothing is a UI-only + /// refinement the shader omits here. /// - /// The C++ chain starts with `get_generate_job()` — a CPU - /// rasterization of the polygon path (QPainterPath bezier fill into - /// an RGBA8888 frame via `generate_frame()`), which a - /// [`ShaderJobPayload`] cannot express: the payload has no generate - /// phase, and the rasterize -> `"rgb"` recolor -> optional `"mrg"` - /// alpha-over chain has no Rust equivalent. The output is kept as a - /// null texture handle marking "renderer must produce this texture"; - /// expressing the chain as payloads is a renderer TODO - /// (`// CPP-PARITY: polygon.cpp` `value()`). + /// The payload params carry `point_count`, the `points_in` array + /// (packed into the std140 uniform block as `vec4[N]`; the renderer + /// pads short arrays to the declared size) and `color_in`; the + /// renderer injects `resolution_in` at job-build time. With an + /// upstream `base_in` connected the payload is handed through + /// `push_mergable_job` (alpha-over the generator output over the + /// base); otherwise it is pushed as the node's output. fn value( &self, core: &NodeCore, @@ -123,12 +209,40 @@ impl NodeBehavior for PolygonGenerator { time: oak_core::Rational, table: &mut crate::value::NodeValueTable, ) { - let _ = (core, inputs, time); - table.push( - crate::value::ValueType::Texture, - crate::value::NodeValue::Texture(crate::handle::CHandle::null()), - None, + // Collect the point array (row element keys, else the node's own + // standard/keyframe values — an unconnected array resolves to the + // default pentagon; see [`point_array`]). + let points = point_array(core, inputs, time); + let point_count = points.len(); + let mut params = inputs.clone(); + params.insert( + POINTS_INPUT.to_string(), + crate::value::NodeValue::Vec4Array(points), ); + params.insert( + "point_count".to_string(), + crate::value::NodeValue::Int(point_count as i64), + ); + // The color is part of the row in the traverser flow (the bare + // key is always inserted for an unconnected input); fall back to + // the node's own value for direct `value()` calls. + if !params.contains_key(COLOR_INPUT) { + params.insert( + COLOR_INPUT.to_string(), + core.value_at_time(COLOR_INPUT, -1, time), + ); + } + let job = crate::handle::make_owned(crate::nodes::jobs::ShaderJobPayload { + node_id: crate::id::NodeId::INVALID, + time, + iterations: 1, + type_id: self.type_id().to_string(), + shader_id: "rgb".to_string(), + effect_input: core.effect_input.clone(), + params, + iterative_input: String::new(), + }); + super::generatorwithmerge::GeneratorWithMerge::push_mergable_job(inputs, job, table); } /// Direct frame generation (C++ `generate_frame()`): clears the RGBA @@ -312,7 +426,7 @@ mod tests { } #[test] - fn value_pushes_deferred_job() { + fn value_pushes_real_shader_job_with_pentagon() { let (core, behavior) = create(); let mut table = NodeValueTable::default(); behavior.value( @@ -321,7 +435,27 @@ mod tests { Rational::new(0, 1), &mut table, ); - assert!(table.get(ValueType::Texture).is_some()); + let tex = table.get(ValueType::Texture).expect("texture pushed"); + let NodeValue::Texture(handle) = tex else { + panic!("pushed value is not a texture handle"); + }; + let job = unsafe { + crate::handle::get_checked::(handle) + } + .expect("real shader job"); + assert_eq!(job.shader_id, "rgb"); + assert_eq!(job.type_id, "org.olivevideoeditor.Olive.polygon"); + match job.params.get("points_in") { + Some(NodeValue::Vec4Array(points)) => { + assert_eq!(points.len(), 5, "default pentagon"); + } + other => panic!("points_in is not a Vec4Array: {other:?}"), + } + assert_eq!(job.params.get("point_count"), Some(&NodeValue::Int(5))); + assert_eq!( + job.params.get(COLOR_INPUT), + Some(&NodeValue::Color([1.0, 1.0, 1.0, 1.0])) + ); } #[test] @@ -340,7 +474,9 @@ mod tests { fn shader_code_dispatches() { let n = PolygonGenerator; let rgb = n.shader_code("rgb").unwrap(); - assert!(rgb.contains("color.rgb = color_in.rgb * color.a;")); + assert!(rgb.contains("points_in[64]")); + assert!(rgb.contains("crossings % 2 == 1")); + assert!(rgb.contains("frag_color = color_in;")); let mrg = n.shader_code("mrg").unwrap(); assert!(mrg.contains("base_col *= 1.0 - blend_col.a;")); assert!(n.shader_code("other").is_none()); diff --git a/crates/oak-node/src/value.rs b/crates/oak-node/src/value.rs index c6a1220ba..95b119f40 100644 --- a/crates/oak-node/src/value.rs +++ b/crates/oak-node/src/value.rs @@ -142,6 +142,9 @@ pub enum NodeValue { Vec4([f64; 4]), /// 4x4 matrix, row-major 16 elements (C++ `k_matrix`). Matrix([f64; 16]), + /// Array of `vec4` values (C++ bezier `points_in` array input; the + /// polygon generator's point table, packed as `vec4[i]` uniforms). + Vec4Array(Vec<[f64; 4]>), /// Combo index. Combo(i64), /// String combo. @@ -410,6 +413,7 @@ impl NodeValue { NodeValue::Vec2(_) => ValueType::Vec2, NodeValue::Vec3(_) => ValueType::Vec3, NodeValue::Vec4(_) => ValueType::Vec4, + NodeValue::Vec4Array(_) => ValueType::Vec4, NodeValue::Matrix(_) => ValueType::Matrix, NodeValue::Combo(_) => ValueType::Combo, NodeValue::StrCombo(_) => ValueType::StrCombo, @@ -710,6 +714,7 @@ impl Clone for NodeValue { NodeValue::Vec2(v) => NodeValue::Vec2(*v), NodeValue::Vec3(v) => NodeValue::Vec3(*v), NodeValue::Vec4(v) => NodeValue::Vec4(*v), + NodeValue::Vec4Array(v) => NodeValue::Vec4Array(v.clone()), NodeValue::Matrix(v) => NodeValue::Matrix(*v), NodeValue::Combo(v) => NodeValue::Combo(*v), NodeValue::StrCombo(v) => NodeValue::StrCombo(v.clone()), diff --git a/crates/oak-render/src/shaderfx.rs b/crates/oak-render/src/shaderfx.rs index ec57fb8b0..0586c9d6a 100644 --- a/crates/oak-render/src/shaderfx.rs +++ b/crates/oak-render/src/shaderfx.rs @@ -56,6 +56,9 @@ pub enum UniformType { Vec4, /// `mat4`. Mat4, + /// `vec4[N]` (an array uniform; the polygon generator's bezier point + /// table is the only consumer, indexed by `[i]` in the shader body). + Vec4Array(usize), } impl UniformType { @@ -74,7 +77,9 @@ impl UniformType { }) } - /// The GLSL keyword back (block re-emission). + /// The GLSL keyword back (block re-emission). Array types have their + /// count appended (`vec4{name}[{count}]`), matching C++ uniform-array + /// declarations. fn keyword(self) -> &'static str { match self { UniformType::Float => "float", @@ -84,6 +89,7 @@ impl UniformType { UniformType::Vec3 => "vec3", UniformType::Vec4 => "vec4", UniformType::Mat4 => "mat4", + UniformType::Vec4Array(_) => "vec4", } } @@ -93,6 +99,7 @@ impl UniformType { UniformType::Float | UniformType::Int | UniformType::Bool => 4, UniformType::Vec2 => 8, UniformType::Vec3 | UniformType::Vec4 | UniformType::Mat4 => 16, + UniformType::Vec4Array(_) => 16, } } @@ -104,6 +111,7 @@ impl UniformType { UniformType::Vec3 => 12, UniformType::Vec4 => 16, UniformType::Mat4 => 64, + UniformType::Vec4Array(n) => 16 * n.max(1), } } } @@ -368,6 +376,17 @@ pub fn pack_uniforms( (UniformType::Vec4, NodeValue::Vec4(v) | NodeValue::Color(v)) => { v.iter().map(|x| *x as f32).collect() } + (UniformType::Vec4Array(n), NodeValue::Vec4Array(v)) => { + let mut out = Vec::with_capacity(n * 4); + for el in v.iter().take(n) { + out.extend(el.iter().map(|x| *x as f32)); + } + // Pad to the declared count (each element is 16 bytes; the + // buffer is sized n*16 regardless). + let have = out.len(); + out.resize((n * 4).max(have), 0.0f32); + out + } // Matrices: GLSL mat4 is column-major; the NodeValue comment // marks the layout row-major, so transpose on the way in. (UniformType::Mat4, NodeValue::Matrix(m)) => { @@ -405,9 +424,11 @@ fn is_sampler_type(kw: &str) -> bool { /// Parse a `uniform ;` declaration line (the constrained /// style of the node shader corpus: one declaration per line, no layout -/// qualifiers, no initializers, no arrays — arrays are reported as -/// unsupported, matching the C++ Blit). Returns `(type, name)`. -fn parse_uniform_line(line: &str) -> Option<(&str, &str)> { +/// qualifiers, no initializers). Arrays ARE supported: +/// `uniform [];` (the polygon generator's point +/// table). `None` for any other line (samplers are handled by the +/// caller). Returns `(base keyword, name, array count)`. +fn parse_uniform_line(line: &str) -> Option<(&str, &str, usize)> { let t = line.trim_start(); let rest = t.strip_prefix("uniform")?; if !rest.starts_with(char::is_whitespace) { @@ -415,11 +436,19 @@ fn parse_uniform_line(line: &str) -> Option<(&str, &str)> { } let rest = rest.trim_start(); let (ty, rest) = rest.split_once(char::is_whitespace)?; - let name = rest.trim().strip_suffix(';')?.trim(); + let rest = rest.trim_start().trim_end_matches(';'); + let (name, count) = if let Some(idx) = rest.find('[') { + let name = rest[..idx].trim(); + let end = rest[idx..].find(']')?; + let count: usize = rest[idx + 1..idx + end].trim().parse().ok()?; + (name, count.max(1)) + } else { + (rest.trim(), 1) + }; if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { return None; } - Some((ty, name)) + Some((ty, name, count)) } /// Translate one GLSL fragment shader to WGSL (naga glsl-in → wgsl-out). @@ -427,26 +456,29 @@ fn parse_uniform_line(line: &str) -> Option<(&str, &str)> { /// docs for the rewrite steps. pub fn translate(glsl: &str) -> Result { let mut body_lines: Vec = Vec::new(); - let mut uniforms: Vec<(UniformType, String)> = Vec::new(); + let mut uniforms: Vec<(UniformType, String, usize)> = Vec::new(); let mut textures: Vec = Vec::new(); for line in glsl.lines() { - if let Some((ty, name)) = parse_uniform_line(line) { + if let Some((ty, name, count)) = parse_uniform_line(line) { if is_sampler_type(ty) { - textures.push(name.to_string()); + for _ in 0..count { + textures.push(name.to_string()); + } continue; } - match UniformType::from_keyword(ty) { - Some(t) => { - uniforms.push((t, name.to_string())); - continue; - } - None => { + let base = UniformType::from_keyword(ty); + let uniform = match (base, count) { + (Some(UniformType::Vec4), n) if n > 1 => UniformType::Vec4Array(n), + (Some(t), n) if n == 1 => t, + _ => { return Err(Error::Failed(format!( - "unsupported uniform type in shader: {ty} {name}" + "unsupported array uniform type in shader: {ty} {name}[{count}]" ))); } - } + }; + uniforms.push((uniform, name.to_string(), count)); + continue; } body_lines.push(line.to_string()); } @@ -465,7 +497,7 @@ pub fn translate(glsl: &str) -> Result { src.push_str("layout(std140, set = 0, binding = "); src.push_str(&UNIFORM_BLOCK_BINDING.to_string()); src.push_str(") uniform OakParams {\n"); - for (ty, name) in &uniforms { + for (ty, name, count) in &uniforms { // bools are declared as int (WGSL has no host-shareable // bool); the body's uses were rewritten to `bool(x)`. let kw = if *ty == UniformType::Bool { @@ -473,7 +505,11 @@ pub fn translate(glsl: &str) -> Result { } else { ty.keyword() }; - src.push_str(&format!(" {kw} {name};\n")); + if *count > 1 { + src.push_str(&format!(" {kw} {name}[{}];\n", count)); + } else { + src.push_str(&format!(" {kw} {name};\n")); + } } src.push_str("};\n"); } @@ -513,7 +549,7 @@ pub fn translate(glsl: &str) -> Result { } // WGSL has no host-shareable bool: bool uniforms live in the // block as `int`, so their uses become `bool(x)` (nonzero test). - for (ty, name) in &uniforms { + for (ty, name, _count) in &uniforms { if *ty == UniformType::Bool { l = replace_ident(&l, name, &format!("bool({name})")); } @@ -556,7 +592,7 @@ pub fn translate(glsl: &str) -> Result { // std140 offsets (declaration order; the block tail pads to 16). let mut offset = 0usize; let mut decls = Vec::with_capacity(uniforms.len()); - for (ty, name) in uniforms { + for (ty, name, _count) in uniforms { let align = ty.align(); offset = offset.next_multiple_of(align); decls.push(UniformDecl { name, ty, offset }); @@ -642,14 +678,65 @@ void main() { assert_eq!(out.uniform_block_bytes, 48); } - /// Array uniforms are unsupported (C++ Blit skipped them too). + /// Array uniforms translate into the block as packed std140 arrays + /// (polygon's `points_in[64]`; the declaration order determines the + /// offsets: `int` @0, the vec4 array aligned to 16 @16, the trailing + /// vec2 aligned to 8). #[test] - fn array_uniforms_are_rejected() { - let glsl = "uniform float taps_in[8];\nvoid main() {}\n"; - // The array syntax is not a parseable `uniform ;` - // line for our parser, so the declaration is left in the body and - // naga sees it — either way the translation must not panic. - let _ = translate(glsl); + fn array_uniforms_translate_and_pack() { + use oak_node::value::{NodeValue, NodeValueRow}; + let glsl = r#" +uniform int point_count; +uniform vec4 points_in[64]; +uniform vec2 resolution_in; + +in vec2 ove_texcoord; +out vec4 frag_color; + +void main() { frag_color = vec4(0.0); } +"#; + let out = translate(glsl).expect("translate array uniforms"); + let decls: Vec<(&str, &UniformType, usize)> = out + .uniforms + .iter() + .map(|u| (u.name.as_str(), &u.ty, u.offset)) + .collect(); + assert_eq!( + decls, + vec![ + ("point_count", &UniformType::Int, 0), + ("points_in", &UniformType::Vec4Array(64), 16), + ("resolution_in", &UniformType::Vec2, 16 + 64 * 16), + ] + ); + assert_eq!( + out.uniform_block_bytes, + (16 + 64 * 16 + 8usize).next_multiple_of(16) + ); + + let mut row = NodeValueRow::new(); + row.insert("point_count".into(), NodeValue::Int(3)); + row.insert( + "points_in".into(), + NodeValue::Vec4Array(vec![[1.0, 2.0, 0.0, 0.0], [3.0, 4.0, 0.0, 0.0]]), + ); + let buf = pack_uniforms(&out, &row); + assert_eq!(buf.len(), out.uniform_block_bytes); + let count = out.uniforms.iter().find(|u| u.name == "point_count").unwrap(); + assert_eq!( + i32::from_le_bytes(buf[count.offset..count.offset + 4].try_into().unwrap()), + 3 + ); + let points = out.uniforms.iter().find(|u| u.name == "points_in").unwrap(); + let at = |i: usize, c: usize| points.offset + i * 16 + c * 4; + assert_eq!(f32::from_le_bytes(buf[at(0, 0)..at(0, 1)].try_into().unwrap()), 1.0); + assert_eq!(f32::from_le_bytes(buf[at(0, 1)..at(0, 2)].try_into().unwrap()), 2.0); + assert_eq!(f32::from_le_bytes(buf[at(1, 0)..at(1, 1)].try_into().unwrap()), 3.0); + // Short arrays pad the remaining slots to zero. + assert_eq!( + f32::from_le_bytes(buf[at(63, 0)..at(63, 1)].try_into().unwrap()), + 0.0 + ); } /// Uniform packing follows the declared types and std140 offsets. @@ -817,6 +904,165 @@ void main() { frag_color = texture(tex_in, ove_texcoord); } ctx.destroy_texture(src); ctx.destroy_texture(dst); } + /// The polygon generator's shader rasterizes the default pentagon on + /// the GPU: the center texel inside the closed point loop is opaque + /// white, the frame corner is transparent, and the 1-point fallback + /// renders nothing. + #[test] + fn gpu_polygon_rasterizes_pentagon() { + let Some(ctx) = gpu() else { + eprintln!("no adapter; skipping"); + return; + }; + let (_core, behavior) = oak_node::factory::Factory::global() + .create_any("org.olivevideoeditor.Olive.polygon") + .unwrap(); + let glsl = behavior.shader_code("rgb").unwrap(); + let effect = compile_effect(&ctx, "test/polygon", &glsl, false).unwrap(); + + let dst = ctx.create_texture(512, 512).unwrap(); + let mut row = oak_node::value::NodeValueRow::new(); + row.insert( + "points_in".into(), + oak_node::value::NodeValue::Vec4Array(vec![ + [0.0, -135.0, 0.0, 0.0], + [135.0, -45.0, 0.0, 0.0], + [90.0, 120.0, 0.0, 0.0], + [-90.0, 120.0, 0.0, 0.0], + [-135.0, -45.0, 0.0, 0.0], + ]), + ); + row.insert("point_count".into(), oak_node::value::NodeValue::Int(5)); + row.insert( + "color_in".into(), + oak_node::value::NodeValue::Color([1.0, 1.0, 1.0, 1.0]), + ); + run_effect(&ctx, &effect, &row, &[], dst, (512, 512), 1).unwrap(); + let out = ctx.download(dst).unwrap(); + + let center = pixel(&out, 256 * 512 + 256); + assert_eq!(center, [1.0, 1.0, 1.0, 1.0], "center is inside the pentagon"); + let corner = pixel(&out, 0); + assert_eq!(corner, [0.0, 0.0, 0.0, 0.0], "corner is outside"); + + // Degenerate: a single point draws nothing. + row.insert("point_count".into(), oak_node::value::NodeValue::Int(1)); + run_effect(&ctx, &effect, &row, &[], dst, (512, 512), 1).unwrap(); + let out = ctx.download(dst).unwrap(); + assert_eq!( + pixel(&out, 256 * 512 + 256), + [0.0, 0.0, 0.0, 0.0], + "single point draws nothing" + ); + + ctx.destroy_texture(dst); + } + + /// The mask effect's shader multiplies the base texture by the + /// pentagon matte on the GPU: the center texel keeps the base value, + /// the corner is cleared, and with `invert_in` the result flips. + #[test] + fn gpu_mask_multiplies_base_by_pentagon() { + let Some(ctx) = gpu() else { + eprintln!("no adapter; skipping"); + return; + }; + let (_core, behavior) = oak_node::factory::Factory::global() + .create_any("org.olivevideoeditor.Olive.mask") + .unwrap(); + let glsl = behavior.shader_code("mask").unwrap(); + let effect = compile_effect(&ctx, "test/mask", &glsl, false).unwrap(); + + let src = ctx.create_texture(512, 512).unwrap(); + let dst = ctx.create_texture(512, 512).unwrap(); + let gray = crate::shaderfx::tests::f32_frame(512, 512, |_| { + [0.5, 0.5, 0.5, 1.0] + }); + ctx.upload(src, &gray).unwrap(); + + let mut row = oak_node::value::NodeValueRow::new(); + row.insert( + "points_in".into(), + oak_node::value::NodeValue::Vec4Array(vec![ + [0.0, -135.0, 0.0, 0.0], + [135.0, -45.0, 0.0, 0.0], + [90.0, 120.0, 0.0, 0.0], + [-90.0, 120.0, 0.0, 0.0], + [-135.0, -45.0, 0.0, 0.0], + ]), + ); + row.insert("point_count".into(), oak_node::value::NodeValue::Int(5)); + row.insert("feather_in".into(), oak_node::value::NodeValue::Float(0.0)); + row.insert("invert_in".into(), oak_node::value::NodeValue::Boolean(false)); + run_effect( + &ctx, + &effect, + &row, + &[("base_in".to_string(), src)], + dst, + (512, 512), + 1, + ) + .unwrap(); + let out = ctx.download(dst).unwrap(); + let center = pixel(&out, 256 * 512 + 256); + assert_eq!(center, [0.5, 0.5, 0.5, 1.0], "center keeps the base"); + assert_eq!(pixel(&out, 0), [0.0, 0.0, 0.0, 0.0], "corner is masked out"); + + // Inverted: the corner keeps the base, the center is cleared. + row.insert("invert_in".into(), oak_node::value::NodeValue::Boolean(true)); + run_effect( + &ctx, + &effect, + &row, + &[("base_in".to_string(), src)], + dst, + (512, 512), + 1, + ) + .unwrap(); + let out = ctx.download(dst).unwrap(); + assert_eq!(pixel(&out, 0), [0.5, 0.5, 0.5, 1.0], "inverted corner keeps base"); + assert_eq!( + pixel(&out, 256 * 512 + 256), + [0.0, 0.0, 0.0, 0.0], + "inverted center cleared" + ); + + // Feather: a square polygon with radius 4 softens the edge — the + // pixel right outside the crisp edge becomes partially visible. + row.insert("invert_in".into(), oak_node::value::NodeValue::Boolean(false)); + row.insert("feather_in".into(), oak_node::value::NodeValue::Float(4.0)); + row.insert( + "points_in".into(), + oak_node::value::NodeValue::Vec4Array(vec![ + [-150.0, -150.0, 0.0, 0.0], + [150.0, -150.0, 0.0, 0.0], + [150.0, 150.0, 0.0, 0.0], + [-150.0, 150.0, 0.0, 0.0], + ]), + ); + row.insert("point_count".into(), oak_node::value::NodeValue::Int(4)); + run_effect( + &ctx, + &effect, + &row, + &[("base_in".to_string(), src)], + dst, + (512, 512), + 1, + ) + .unwrap(); + let out = ctx.download(dst).unwrap(); + let soft = pixel(&out, 408 * 512 + 256)[0]; + assert!( + soft > 0.02 && soft < 0.6, + "just outside the feathered edge is partially visible: {soft}" + ); + + ctx.destroy_texture(src); + ctx.destroy_texture(dst); + } /// Every registered node type that ships a shader must translate (the /// all-shaders sweep). OCIO-stubbed shaders (`%1` markers needing the