diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ad6b30a..170c33b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ This release has an [MSRV][] of 1.86. ## Added - `register_texture`, a helper for using `wgpu` textures in a Vello `Renderer`. ([#1161][] by [@DJMcNab][]) +- `push_luminance_mask_layer`, content within which is used as a luminance mask. ([#1183][] by [@DJMcNab][]). + This is a breaking change to Vello Encoding. ## Fixed @@ -316,6 +318,7 @@ This release has an [MSRV][] of 1.75. [#1161]: https://github.com/linebender/vello/pull/1161 [#1169]: https://github.com/linebender/vello/pull/1169 [#1182]: https://github.com/linebender/vello/pull/1182 +[#1183]: https://github.com/linebender/vello/pull/1183 [#1187]: https://github.com/linebender/vello/pull/1187 diff --git a/examples/scenes/src/test_scenes.rs b/examples/scenes/src/test_scenes.rs index 016bf2ecb..ba16f3f68 100644 --- a/examples/scenes/src/test_scenes.rs +++ b/examples/scenes/src/test_scenes.rs @@ -85,6 +85,8 @@ export_scenes!( image_sampling(image_sampling), image_extend_modes_bilinear(impls::image_extend_modes(ImageQuality::Medium), "image_extend_modes (bilinear)", false), image_extend_modes_nearest_neighbor(impls::image_extend_modes(ImageQuality::Low), "image_extend_modes (nearest neighbor)", false), + luminance_mask(luminance_mask), + image_luminance_mask(image_luminance_mask), ); /// Implementations for the test scenes. @@ -1870,4 +1872,137 @@ mod impls { ); } } + + pub(super) fn luminance_mask(scene: &mut Scene, params: &mut SceneParams<'_>) { + params.resolution = Some((55., 55.).into()); + // Porter-Duff "over" pure white, to match example in https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/mask-type + // Note that using `base_color` doesn't work here, because of how it interacts with masks + scene.fill( + Fill::EvenOdd, + Affine::IDENTITY, + Color::WHITE, + None, + &Rect { + x0: 0., + y0: 0., + x1: 60., + y1: 60., + }, + ); + scene.push_layer( + BlendMode::new(Mix::Normal, Compose::SrcOver), + 1.0, + Affine::IDENTITY, + &Rect { + x0: 5., + y0: 5., + x1: 50., + y1: 50., + }, + ); + scene.fill( + Fill::EvenOdd, + Affine::IDENTITY, + palette::css::RED, + None, + &Rect { + x0: 5., + y0: 5., + x1: 50., + y1: 50., + }, + ); + scene.push_luminance_mask_layer( + 1.0, + Affine::IDENTITY, + &Rect { + x0: 5., + y0: 5., + x1: 50., + y1: 50., + }, + ); + scene.fill( + Fill::EvenOdd, + Affine::IDENTITY, + color::parse_color("rgb(10% 10% 10% / 0.4)").unwrap(), + None, + &Rect { + x0: 5., + y0: 5., + x1: 50., + y1: 50., + }, + ); + scene.fill( + Fill::EvenOdd, + Affine::IDENTITY, + color::parse_color("rgb(90% 90% 90% / 0.6)").unwrap(), + None, + &Circle { + center: (0., 55.).into(), + radius: 35., + }, + ); + scene.pop_layer(); + scene.pop_layer(); + } + + pub(super) fn image_luminance_mask(scene: &mut Scene, params: &mut SceneParams<'_>) { + // Flower image is 640x480 + params.resolution = Some((700., 500.).into()); + let flower_image = params + .images + .from_bytes(FLOWER_IMAGE.as_ptr() as usize, FLOWER_IMAGE) + .unwrap(); + // HACK: Porter-Duff "over" the base color, restoring full alpha + scene.push_layer( + BlendMode::new(Mix::Normal, Compose::SrcOver), + 1.0, + Affine::IDENTITY, + &Rect { + x0: 0., + y0: 0., + x1: 700., + y1: 500., + }, + ); + scene.fill( + Fill::EvenOdd, + Affine::IDENTITY, + palette::css::BEIGE, + None, + &Rect { + x0: 0., + y0: 0., + x1: 640., + y1: 240., + }, + ); + scene.fill( + Fill::EvenOdd, + Affine::IDENTITY, + palette::css::AQUAMARINE, + None, + &Rect { + x0: 0., + y0: 240., + x1: 320., + y1: 480., + }, + ); + scene.push_luminance_mask_layer( + 1.0, + Affine::IDENTITY, + &Rect { + x0: 0., + y0: 0., + x1: 640., + y1: 480., + }, + ); + scene.draw_image(&flower_image, Affine::IDENTITY); + scene.pop_layer(); + scene.pop_layer(); + } } diff --git a/vello/src/scene.rs b/vello/src/scene.rs index 5b67a0973..6533360c4 100644 --- a/vello/src/scene.rs +++ b/vello/src/scene.rs @@ -21,7 +21,7 @@ use skrifa::{ }; #[cfg(feature = "bump_estimate")] use vello_encoding::BumpAllocatorMemory; -use vello_encoding::{Encoding, Glyph, GlyphRun, NormalizedCoord, Patch, Transform}; +use vello_encoding::{DrawBeginClip, Encoding, Glyph, GlyphRun, NormalizedCoord, Patch, Transform}; // TODO - Document invariants and edge cases (#470) // - What happens when we pass a transform matrix with NaN values to the Scene? @@ -100,6 +100,50 @@ impl Scene { if blend.mix == Mix::Clip && alpha != 1.0 { log::warn!("Clip mix mode used with semitransparent alpha"); } + self.push_layer_inner( + DrawBeginClip::new(blend, alpha.clamp(0.0, 1.0)), + transform, + clip, + ); + } + + /// Pushes a new layer clipped by the specified shape and treated like a luminance + /// mask for previous layers. + /// + /// That is, content drawn between this and the next `pop_layer` call will serve + /// as a luminance mask + /// + /// Every drawing command after this call will be clipped by the shape + /// until the layer is popped. + /// + /// **However, the transforms are *not* saved or modified by the layer stack.** + /// + /// # Transparency and premultiplication + /// + /// In the current version of Vello, this can lead to some unexpected behaviour + /// when it is used to draw directly onto a render target which disregards transparency + /// (which includes surfaces in most cases). + /// This happens because the luminance mask only impacts the transparency of the returned value, + /// so if the transparency is ignored, it looks like the result had no effect. + /// + /// This issue only occurs if there are no intermediate opaque layers, so can be worked around + /// by drawing something opaque (or having an opaque `base_color`), then putting a layer around your entire scene + /// with a [`Compose::SrcOver`]. + pub fn push_luminance_mask_layer(&mut self, alpha: f32, transform: Affine, clip: &impl Shape) { + self.push_layer_inner( + DrawBeginClip::luminance_mask(alpha.clamp(0.0, 1.0)), + transform, + clip, + ); + } + + /// Helper for logic shared between [`Self::push_layer`] and [`Self::push_luminance_mask_layer`] + fn push_layer_inner( + &mut self, + parameters: DrawBeginClip, + transform: Affine, + clip: &impl Shape, + ) { let t = Transform::from_kurbo(&transform); self.encoding.encode_transform(t); self.encoding.encode_fill_style(Fill::NonZero); @@ -117,8 +161,7 @@ impl Scene { #[cfg(feature = "bump_estimate")] self.estimator.count_path(clip.path_elements(0.1), &t, None); } - self.encoding - .encode_begin_clip(blend, alpha.clamp(0.0, 1.0)); + self.encoding.encode_begin_clip(parameters); } /// Pops the current layer. diff --git a/vello_encoding/src/draw.rs b/vello_encoding/src/draw.rs index 8191ea17b..a80364364 100644 --- a/vello_encoding/src/draw.rs +++ b/vello_encoding/src/draw.rs @@ -196,13 +196,28 @@ pub struct DrawBeginClip { } impl DrawBeginClip { - /// Creates new clip draw data. + /// The `blend_mode` used to indicate that a layer should be + /// treated as a luminance mask. + /// + /// The least significant 16 bits are reserved for Mix + Compose + /// combinations. + pub const LUMINANCE_MASK_LAYER: u32 = 0x10000; + + /// Creates new clip draw data for a Porter-Duff blend mode. pub fn new(blend_mode: BlendMode, alpha: f32) -> Self { Self { blend_mode: ((blend_mode.mix as u32) << 8) | blend_mode.compose as u32, alpha, } } + + /// Creates a new clip draw data for a luminance mask. + pub fn luminance_mask(alpha: f32) -> Self { + Self { + blend_mode: Self::LUMINANCE_MASK_LAYER, + alpha, + } + } } /// Monoid for the draw tag stream. diff --git a/vello_encoding/src/encoding.rs b/vello_encoding/src/encoding.rs index ddad6c7d2..d158801ae 100644 --- a/vello_encoding/src/encoding.rs +++ b/vello_encoding/src/encoding.rs @@ -1,6 +1,8 @@ // Copyright 2022 the Vello Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +use crate::DrawBeginClip; + use super::{ DrawBlurRoundedRect, DrawColor, DrawImage, DrawLinearGradient, DrawRadialGradient, DrawSweepGradient, DrawTag, Glyph, GlyphRun, NormalizedCoord, Patch, PathEncoder, PathTag, @@ -9,7 +11,7 @@ use super::{ use peniko::color::{DynamicColor, palette}; use peniko::kurbo::{Shape, Stroke}; -use peniko::{BlendMode, BrushRef, ColorStop, Extend, Fill, GradientKind, Image}; +use peniko::{BrushRef, ColorStop, Extend, Fill, GradientKind, Image}; /// Encoded data streams for a scene. /// @@ -461,13 +463,10 @@ impl Encoding { } /// Encodes a begin clip command. - pub fn encode_begin_clip(&mut self, blend_mode: BlendMode, alpha: f32) { - use super::DrawBeginClip; + pub fn encode_begin_clip(&mut self, parameters: DrawBeginClip) { self.draw_tags.push(DrawTag::BEGIN_CLIP); self.draw_data - .extend_from_slice(bytemuck::cast_slice(bytemuck::bytes_of( - &DrawBeginClip::new(blend_mode, alpha), - ))); + .extend_from_slice(bytemuck::cast_slice(bytemuck::bytes_of(¶meters))); self.n_clips += 1; self.n_open_clips += 1; } diff --git a/vello_shaders/shader/fine.wgsl b/vello_shaders/shader/fine.wgsl index cd1a64d5e..9276871ff 100644 --- a/vello_shaders/shader/fine.wgsl +++ b/vello_shaders/shader/fine.wgsl @@ -29,6 +29,8 @@ const IMAGE_QUALITY_LOW = 0u; const IMAGE_QUALITY_MEDIUM = 1u; const IMAGE_QUALITY_HIGH = 2u; +const LUMINANCE_MASK_LAYER = 0x10000u; + @group(0) @binding(2) var ptcl: array; @@ -1017,7 +1019,20 @@ fn main( } let bg = unpack4x8unorm(bg_rgba); let fg = rgba[i] * area[i] * end_clip.alpha; - rgba[i] = blend_mix_compose(bg, fg, end_clip.blend); + if end_clip.blend == LUMINANCE_MASK_LAYER { + // TODO: Does this case apply more generally? + // See https://github.com/linebender/vello/issues/1061 + // TODO: How do we handle anti-aliased edges here? + // This is really an imaging model question + if area[i] == 0f { + rgba[i] = bg; + continue; + } + let luminance = clamp(svg_lum(unpremultiply(fg)) * fg.a, 0.0, 1.0); + rgba[i] = bg * luminance; + } else { + rgba[i] = blend_mix_compose(bg, fg, end_clip.blend); + } } cmd_ix += 3u; } @@ -1226,6 +1241,7 @@ fn main( let coords = xy_uint + vec2(i, 0u); if coords.x < config.target_width && coords.y < config.target_height { let fg = rgba[i]; + // let fg = base_color * (1.0 - foreground.a) + foreground; // Max with a small epsilon to avoid NaNs let a_inv = 1.0 / max(fg.a, 1e-6); let rgba_sep = vec4(fg.rgb * a_inv, fg.a); diff --git a/vello_shaders/shader/shared/blend.wgsl b/vello_shaders/shader/shared/blend.wgsl index 48510a8f5..cb71dfa6c 100644 --- a/vello_shaders/shader/shared/blend.wgsl +++ b/vello_shaders/shader/shared/blend.wgsl @@ -75,6 +75,11 @@ fn lum(c: vec3) -> f32 { return dot(c, f); } +fn svg_lum(c: vec3) -> f32 { + let f = vec3(0.2125, 0.7154, 0.0721); + return dot(c, f); +} + fn clip_color(c_in: vec3) -> vec3 { var c = c_in; let l = lum(c); @@ -218,11 +223,11 @@ fn blend_compose( cs: vec3, ab: f32, as_: f32, - mode: u32 + compose_mode: u32, ) -> vec4 { var fa = 0.0; var fb = 0.0; - switch mode { + switch compose_mode { case COMPOSE_COPY: { fa = 1.0; fb = 0.0; @@ -283,20 +288,24 @@ fn blend_compose( return vec4(co, min(as_fa + ab_fb, 1.0)); } +fn unpremultiply(color: vec4) -> vec3 { + let EPSILON = 1e-15; + // Max with a small epsilon to avoid NaNs. + let inv_alpha = 1.0 / max(color.a, EPSILON); + return color.rgb * inv_alpha; +} + // Apply color mixing and composition. Both input and output colors are // premultiplied RGB. fn blend_mix_compose(backdrop: vec4, src: vec4, mode: u32) -> vec4 { let BLEND_DEFAULT = ((MIX_NORMAL << 8u) | COMPOSE_SRC_OVER); - let EPSILON = 1e-15; if (mode & 0x7fffu) == BLEND_DEFAULT { // Both normal+src_over blend and clip case return backdrop * (1.0 - src.a) + src; } - // Un-premultiply colors for blending. Max with a small epsilon to avoid NaNs. - let inv_src_a = 1.0 / max(src.a, EPSILON); - var cs = src.rgb * inv_src_a; - let inv_backdrop_a = 1.0 / max(backdrop.a, EPSILON); - let cb = backdrop.rgb * inv_backdrop_a; + // Un-premultiply colors for blending. + var cs = unpremultiply(src); + let cb = unpremultiply(backdrop); let mix_mode = mode >> 8u; let mixed = blend_mix(cb, cs, mix_mode); cs = mix(cs, mixed, backdrop.a); diff --git a/vello_tests/snapshots/image_luminance_mask.png b/vello_tests/snapshots/image_luminance_mask.png new file mode 100644 index 000000000..f36361407 --- /dev/null +++ b/vello_tests/snapshots/image_luminance_mask.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc1fa4ec6bca0be8a70b3bc3e3dff7847b90817e67c6e225c5246690a072f63c +size 49877 diff --git a/vello_tests/snapshots/luminance_mask.png b/vello_tests/snapshots/luminance_mask.png new file mode 100644 index 000000000..cdff973b2 --- /dev/null +++ b/vello_tests/snapshots/luminance_mask.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af11a41dc1b174ddb89d414bdc72673b3fcd8350ffdbecfc66142dab45873de5 +size 268 diff --git a/vello_tests/tests/snapshot_test_scenes.rs b/vello_tests/tests/snapshot_test_scenes.rs index b406edf56..6c9ab1071 100644 --- a/vello_tests/tests/snapshot_test_scenes.rs +++ b/vello_tests/tests/snapshot_test_scenes.rs @@ -137,3 +137,21 @@ fn snapshot_image_extend_modes_nearest_neighbor() { let params = TestParams::new("image_extend_modes_nearest_neighbor", 400, 400); snapshot_test_scene(test_scene, params); } + +#[test] +#[cfg_attr(skip_gpu_tests, ignore)] +fn snapshot_luminance_mask() { + let test_scene = test_scenes::luminance_mask(); + // This has been manually validated to match the example in + // https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/mask-type + let params = TestParams::new("luminance_mask", 55, 55); + snapshot_test_scene(test_scene, params); +} + +#[test] +#[cfg_attr(skip_gpu_tests, ignore)] +fn image_luminance_mask() { + let test_scene = test_scenes::image_luminance_mask(); + let params = TestParams::new("image_luminance_mask", 350, 250); + snapshot_test_scene(test_scene, params); +}