diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc index 9c19bc65..60063d14 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -376,6 +376,37 @@ void native_document::append_scene( const auto brightness_filter = resolve_single_filter("brightness", 10.0F); const auto grayscale_filter = resolve_single_filter("grayscale", 1.0F); const auto contrast_filter = resolve_single_filter("contrast", 10.0F); + const auto blur_filter = [&]() -> std::optional { + const auto& effects = node.style.textual().effect_values; + const auto known = effects.find("filter"); + if (known == effects.end()) return std::nullopt; + auto value = std::string_view{known->second}; + constexpr auto name = std::string_view{"blur"}; + if (value.size() < name.size() + 2U + || value[name.size()] != '(' || value.back() != ')') return std::nullopt; + for (size_t index = 0; index < name.size(); ++index) { + const auto character = static_cast(value[index]); + const auto lower = character >= 'A' && character <= 'Z' + ? static_cast(character - 'A' + 'a') + : static_cast(character); + if (lower != name[index]) return std::nullopt; + } + value.remove_prefix(name.size() + 1U); + value.remove_suffix(1U); + while (!value.empty() + && std::isspace(static_cast(value.front()))) + value.remove_prefix(1U); + while (!value.empty() + && std::isspace(static_cast(value.back()))) + value.remove_suffix(1U); + const auto parsed = css::parse_ascii_number_prefix(value); + if (!parsed.has_value() || parsed->value < 0.0F) return std::nullopt; + const auto suffix = value.substr(parsed->consumed); + if (suffix != "px" && !(suffix.empty() && parsed->value == 0.0F)) + return std::nullopt; + return parsed->value <= 256.0F + ? std::optional{parsed->value} : std::nullopt; + }(); const auto has_opacity_group = opacity < 0.999F; if (has_opacity_group) { commands.push_back(webscene_scene_command{ @@ -439,6 +470,22 @@ void native_document::append_scene( 0, *contrast_filter}); } + if (blur_filter.has_value()) { + commands.push_back(webscene_scene_command{ + 30U, + 1U << 28U, + node.layout.x, + node.layout.y, + node.layout.width, + node.layout.height, + 255U, + node.id, + 0, + 0, + 0, + 0, + *blur_filter}); + } const auto resolve_transform_origin = [](css_length value, float available, float fallback) { if (value.unit == length_unit::pixels) return value.value; if (value.unit == length_unit::percent) { @@ -1999,6 +2046,11 @@ void native_document::append_scene( if (has_scale_transform) { commands.push_back(webscene_scene_command{16U, 0U, 0, 0, 0, 0, 0U, node.id}); } + if (blur_filter.has_value()) { + commands.push_back(webscene_scene_command{ + 31U, 0U, node.layout.x, node.layout.y, + node.layout.width, node.layout.height, 0U, node.id}); + } if (contrast_filter.has_value()) { commands.push_back(webscene_scene_command{ 31U, 0U, node.layout.x, node.layout.y, @@ -2366,6 +2418,11 @@ void native_document::append_scene( if (has_scale_transform) { commands.push_back(webscene_scene_command{16U, 0U, 0, 0, 0, 0, 0U, node.id}); } + if (blur_filter.has_value()) { + commands.push_back(webscene_scene_command{ + 31U, 0U, node.layout.x, node.layout.y, + node.layout.width, node.layout.height, 0U, node.id}); + } if (contrast_filter.has_value()) { commands.push_back(webscene_scene_command{ 31U, 0U, node.layout.x, node.layout.y, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index 0b6a3073..be5dfc47 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -156,7 +156,8 @@ typedef struct webscene_scene_header { // scene string; the remaining bits are its string index. A zero flag retains // the rounded-rectangle fields used by existing producers and presenters. // Group kind 30 uses flags bit 31 for brightness, bit 30 for grayscale, and -// bit 29 for contrast; stroke_width carries the bounded non-negative amount. +// bit 29 for contrast, and bit 28 for foreground blur; stroke_width carries +// the bounded non-negative multiplier or CSS blur standard deviation. // A zero flag retains the opacity-group alpha stored in the low byte of rgba. typedef struct webscene_scene_command { uint32_t kind; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp index 1e337021..f4d902a6 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp @@ -54,6 +54,7 @@ struct clip_scene_counts final { uint32_t inset_clip_begins{}; uint32_t inset_clip_ends{}; uint32_t clipped_fills{}; + uint32_t blur_filter_begins{}; uint32_t command_count{}; bool transform_clip_nested{}; }; @@ -105,6 +106,10 @@ clip_scene_counts wait_for_inset_clip_scene(webscene_engine* engine, uint32_t ex } else if ((command.kind == 1U || command.kind == 9U) && command.rgba == 0x285078FFU) { ++latest.clipped_fills; + } else if (command.kind == 30U + && (command.flags & (1U << 28U)) != 0U + && std::abs(command.stroke_width - 2.0F) < 0.01F) { + ++latest.blur_filter_begins; } } webscene_scene_acknowledge_v3(lease); @@ -177,6 +182,7 @@ int main() clip-path: inset(0px 1px); filter: brightness(0.5); backdrop-filter: blur(1px); } #effects.alternate > span { clip-path: circle(25%); filter: contrast(2); } #effects > span:first-child { transform: scale(1.25) rotate(3deg); } + #effects > span:last-child { filter: blur(2px); } `; document.head.appendChild(rules); const host = document.createElement('main'); @@ -193,6 +199,9 @@ int main() || first.offsetWidth !== 8 || first.offsetHeight !== 2) { throw new Error('initial effect values failed'); } + if (getComputedStyle(host.lastElementChild).getPropertyValue('filter') !== 'blur(2px)') { + throw new Error('initial blur filter value failed'); + } })() )JS", "native-effects-fixture.js"); @@ -259,6 +268,8 @@ int main() "retained scene did not preserve all 4096 clipped fills"); require(initial_clip_scene.transform_clip_nested, "transform commands did not wrap the inset clip scope"); + require(initial_clip_scene.blur_filter_begins == 1U, + "retained scene did not emit the bounded foreground blur group"); const auto initial_scene_command_bytes = static_cast(initial_clip_scene.command_count) * sizeof(webscene_scene_command); @@ -370,6 +381,7 @@ int main() << " clip-begins=" << initial_clip_scene.inset_clip_begins << " clip-ends=" << initial_clip_scene.inset_clip_ends << " clipped-fills=" << initial_clip_scene.clipped_fills + << " blur-filter-begins=" << initial_clip_scene.blur_filter_begins << " transform-clip-nested=" << initial_clip_scene.transform_clip_nested << " initial-scene-command-bytes=" << initial_scene_command_bytes << " peak-textual-style-count-delta=" << peak_textual_style_count_delta diff --git a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs index 6e3e09d1..f74eb468 100644 --- a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs +++ b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs @@ -47,8 +47,10 @@ internal sealed unsafe partial class NativeCanvasSceneRenderer private const uint DomBrightnessFilter = 1u << 31; private const uint DomGrayscaleFilter = 1u << 30; private const uint DomContrastFilter = 1u << 29; + private const uint DomBlurFilter = 1u << 28; private const uint DomColorFilterMask = DomBrightnessFilter | DomGrayscaleFilter | DomContrastFilter; + private const uint DomEffectFilterMask = DomColorFilterMask | DomBlurFilter; private readonly Dictionary s_layers = new(); private readonly List s_orderedLayers = []; @@ -840,19 +842,28 @@ private static void SaveDomGroup( in SceneCommand command, SKPaint paint) { + paint.ColorFilter = null; + paint.ImageFilter = null; paint.Color = new SKColor( 255, 255, 255, - (command.Flags & DomColorFilterMask) != 0 + (command.Flags & DomEffectFilterMask) != 0 ? (byte)255 : (byte)(command.Rgba & 0xff)); - if ((command.Flags & DomColorFilterMask) == 0) + if ((command.Flags & DomEffectFilterMask) == 0) { - paint.ColorFilter = null; canvas.SaveLayer(paint); return; } var amount = Math.Max(0, command.StrokeWidth); + if ((command.Flags & DomBlurFilter) != 0) + { + using var blur = SKImageFilter.CreateBlur(amount, amount); + paint.ImageFilter = blur; + canvas.SaveLayer(paint); + paint.ImageFilter = null; + return; + } float[] matrix; if ((command.Flags & DomBrightnessFilter) != 0) { diff --git a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart index d2c80784..248e2871 100644 --- a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart +++ b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart @@ -22,8 +22,10 @@ const int _domPolygonClipIndexMask = _domPolygonClipResource - 1; const int _domBrightnessFilter = 1 << 31; const int _domGrayscaleFilter = 1 << 30; const int _domContrastFilter = 1 << 29; +const int _domBlurFilter = 1 << 28; const int _domColorFilterMask = _domBrightnessFilter | _domGrayscaleFilter | _domContrastFilter; +const int _domEffectFilterMask = _domColorFilterMask | _domBlurFilter; final class SceneApplyResult { const SceneApplyResult({ @@ -963,11 +965,15 @@ final class WebSceneSceneProjector extends ChangeNotifier { } static ui.Paint _domGroupPaint(WebSceneSceneCommand command) { - if (command.flags & _domColorFilterMask == 0) { + if (command.flags & _domEffectFilterMask == 0) { return ui.Paint() ..color = ui.Color.fromARGB(command.rgba & 0xff, 255, 255, 255); } final amount = command.strokeWidth.clamp(0.0, double.infinity).toDouble(); + if (command.flags & _domBlurFilter != 0) { + return ui.Paint() + ..imageFilter = ui.ImageFilter.blur(sigmaX: amount, sigmaY: amount); + } final List matrix; if (command.flags & _domBrightnessFilter != 0) { matrix = [ diff --git a/tests/WebPlatformSubset/contracts/css-retained-effect-values.html b/tests/WebPlatformSubset/contracts/css-retained-effect-values.html index d5bcb173..cead6bd3 100644 --- a/tests/WebPlatformSubset/contracts/css-retained-effect-values.html +++ b/tests/WebPlatformSubset/contracts/css-retained-effect-values.html @@ -98,6 +98,7 @@ effect.style.setProperty('filter', 'blur(2px)'); effect.style.setProperty('clip-path', 'inset(2px)'); const after = effect.getBoundingClientRect(); + assert_equals(getComputedStyle(effect).getPropertyValue('filter'), 'blur(2px)'); assert_equals(after.width, before.width); assert_equals(after.height, before.height); }, 'effect syntax does not change principal box geometry');