diff --git a/docs/validation/css-retained-effect-values-256.md b/docs/validation/css-retained-effect-values-256.md index 2e0bc516..7b04ebfd 100644 --- a/docs/validation/css-retained-effect-values-256.md +++ b/docs/validation/css-retained-effect-values-256.md @@ -12,6 +12,25 @@ This gate covers the first product-neutral slice of issue #256: Retained-scene mask, clip, filter, and backdrop painting is outside this slice and remains separately qualified by later issue #256 work. +## Backdrop command extension — 19 September 2026 + +Issue #503 extends this contract with a bounded kind-48 retained command. The +resource preserves authored `blur()`/`saturate()` order, the command carries +rounded output bounds and maximum blur sigma, and localized damage includes a +backdrop when earlier sampled content changes. The producer forces these boxes +into the foreground phase so reference presenters execute the command after +retained canvas/GPU layers and before the element background. + +The native regression now authors 4,096 backdrop commands, the WebKit alias, +the Code OSS `blur(8px) saturate(1.08)` sequence, phase metadata, and resource +bounds. Avalonia 12 records Skia backdrop save layers; Flutter replays bounded +prior content through nested filters. AppScene #174 owns the packaged native +Skia/Graphite consumer. + +These additions have not been executed under the current fast-merge directive. +Build, WPT, pixels, runtime, performance, memory, and package results remain +deferred and must not be inferred from the historical results below. + ## Direct gates | Gate | Denominator | Result | diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_declarations.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_declarations.h index e6ba2345..f97ea7e4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_declarations.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_declarations.h @@ -25,6 +25,7 @@ inline void append_declaration( const auto custom = name.starts_with("--"); if (custom && !valid_custom_property_name(name)) return; if (name == "-webkit-mask") name = "mask"; + else if (name == "-webkit-backdrop-filter") name = "backdrop-filter"; else if (name == "-webkit-mask-image") name = "mask-image"; else if (name == "-webkit-mask-position") name = "mask-position"; else if (name == "-webkit-mask-size") name = "mask-size"; 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 de9fee41..547068a1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -467,6 +467,132 @@ void native_document::append_scene( } return result; }(); + struct retained_backdrop_effect final { + bool present{}; + std::string resource; + float maximum_blur_sigma{}; + }; + const auto backdrop_effect = [&]() { + retained_backdrop_effect result{}; + const auto& effects = node.style.textual().effect_values; + const auto known = effects.find("backdrop-filter"); + if (known == effects.end()) return result; + auto remaining = std::string_view{known->second}; + const auto trim = [](std::string_view value) { + 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); + } + return value; + }; + const auto equals_ascii_case_insensitive = [](std::string_view left, + std::string_view right) { + if (left.size() != right.size()) return false; + for (size_t index = 0U; index < left.size(); ++index) { + const auto character = static_cast(left[index]); + const auto lower = character >= 'A' && character <= 'Z' + ? static_cast(character - 'A' + 'a') + : static_cast(character); + if (lower != right[index]) return false; + } + return true; + }; + remaining = trim(remaining); + if (remaining.empty() + || equals_ascii_case_insensitive(remaining, "none")) { + return result; + } + result.present = true; + const auto invalid = [&]() { + result.resource = "webscene-backdrop-invalid-v1"; + result.maximum_blur_sigma = 0.0F; + return result; + }; + std::ostringstream resource; + resource << "webscene-backdrop-v1\t"; + constexpr size_t maximum_filter_functions = 16U; + size_t function_count = 0U; + while (!remaining.empty()) { + if (++function_count > maximum_filter_functions) return invalid(); + const auto opening = remaining.find('('); + auto closing = std::string_view::npos; + if (opening != std::string_view::npos) { + auto depth = 1U; + for (auto index = opening + 1U; index < remaining.size(); ++index) { + if (remaining[index] == '(') ++depth; + else if (remaining[index] == ')' && --depth == 0U) { + closing = index; + break; + } + } + } + if (opening == 0U || opening == std::string_view::npos + || closing == std::string_view::npos) { + return invalid(); + } + const auto name = trim(remaining.substr(0U, opening)); + const auto argument = trim( + remaining.substr(opening + 1U, closing - opening - 1U)); + float amount = -1.0F; + if (equals_ascii_case_insensitive(name, "blur")) { + const auto length = parse_length(std::string{argument}); + switch (length.unit) { + case length_unit::pixels: + case length_unit::em: + case length_unit::rem: + case length_unit::viewport_width: + case length_unit::viewport_height: + case length_unit::viewport_width_capped: + case length_unit::viewport_height_capped: + case length_unit::viewport_width_floored: + case length_unit::viewport_height_floored: + case length_unit::container_inline: + case length_unit::container_block: + case length_unit::container_min: + case length_unit::container_max: + amount = resolve_length(node, length, 0.0F, -1.0F); + break; + default: + break; + } + constexpr auto maximum_backdrop_blur_sigma = 64.0F; + if (!std::isfinite(amount) || amount < 0.0F + || amount > maximum_backdrop_blur_sigma) { + return invalid(); + } + result.maximum_blur_sigma = std::max( + result.maximum_blur_sigma, amount); + resource << (function_count == 1U ? "" : ";") + << "blur=" << amount; + } else if (equals_ascii_case_insensitive(name, "saturate")) { + const auto parsed = css::parse_ascii_number_prefix(argument); + if (!parsed.has_value() || parsed->value < 0.0F) return invalid(); + const auto suffix = argument.substr(parsed->consumed); + amount = suffix.empty() + ? parsed->value + : suffix == "%" ? parsed->value / 100.0F : -1.0F; + if (!std::isfinite(amount) || amount < 0.0F || amount > 10.0F) { + return invalid(); + } + resource << (function_count == 1U ? "" : ";") + << "saturate=" << amount; + } else { + return invalid(); + } + remaining.remove_prefix(closing + 1U); + if (!remaining.empty() + && !std::isspace(static_cast(remaining.front()))) { + return invalid(); + } + remaining = trim(remaining); + } + result.resource = resource.str(); + return result; + }(); const auto mask_resource = [&]() -> std::optional { const auto& effects = node.style.textual().effect_values; const auto known = effects.find("mask-image"); @@ -1280,7 +1406,9 @@ void native_document::append_scene( // backgrounds must be emitted after retained chart canvases, just // like their text and SVG foreground commands. } - return is_in_modal_layer(node) || node.paints_after_retained_canvas || fixed_layer || outermost_z_index > 0; + return backdrop_effect.present || is_in_modal_layer(node) + || node.paints_after_retained_canvas || fixed_layer + || outermost_z_index > 0; }(); struct resolved_radii final { float top_left; @@ -1707,6 +1835,29 @@ void native_document::append_scene( node.style.border_bottom_left_radius_y(), node.layout.width, node.layout.height); + if (paint_self && backdrop_effect.present + && node.layout.width > 0.0F && node.layout.height > 0.0F) { + append_ellipse_metadata( + radii, + node.layout.x, + node.layout.y, + node.layout.width, + node.layout.height); + commands.push_back(webscene_scene_command{ + 48U, + append_scene_string(backdrop_effect.resource, strings, string_bytes), + node.layout.x, + node.layout.y, + node.layout.width, + node.layout.height, + 1U, + node.id, + radii.top_left, + radii.top_right, + radii.bottom_right, + radii.bottom_left, + backdrop_effect.maximum_blur_sigma}); + } if (paint_self && node.style.box_shadow_present && !node.style.box_shadow_inset && node.layout.width > 0 diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index b4524d8c..67f1a025 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -168,6 +168,13 @@ typedef struct webscene_scene_header { // webscene-mask-svg-v1 tiled SVG resource to the current isolated layer using // destination-in before kind 31 restores it. webscene-mask-invalid-v1 clears // the isolated layer for failed or unsupported authored masks. +// DOM kind 48 applies one bounded backdrop effect before the element's own +// background and descendants. flags indexes a webscene-backdrop-v1 resource +// containing the authored blur/saturate sequence; rgba is the paint phase, +// the box and corner radii bound output, and stroke_width is the maximum blur +// sigma used to bound sampling and damage. Consumers must sample pixels already +// painted at this exact command position, including retained canvas/GPU output. +// webscene-backdrop-invalid-v1 is an explicit fail-closed no-op. typedef struct webscene_scene_command { uint32_t kind; uint32_t flags; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc index 4c7453f4..1fb20807 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc @@ -81,7 +81,8 @@ bool command_uses_dom_string(const webscene_scene_command& command) { return (command.kind >= 3U && command.kind <= 6U) || (command.kind == 12U && (command.flags & (1U << 31U)) != 0U) - || command.kind == 47U; + || command.kind == 47U + || command.kind == 48U; } std::string_view command_dom_string( @@ -166,7 +167,13 @@ webscene_damage_rect command_damage_bounds( bottom = center_y + radius; } constexpr auto antialias_padding = 2.0F; - const auto padding = antialias_padding + std::max(0.0F, effect_padding); + constexpr auto backdrop_blur_extent_multiplier = 3.0F; + const auto backdrop_padding = command.kind == 48U + ? std::clamp(command.stroke_width, 0.0F, 64.0F) + * backdrop_blur_extent_multiplier + : 0.0F; + const auto padding = antialias_padding + + std::max(std::max(0.0F, effect_padding), backdrop_padding); return { left - padding, top - padding, @@ -241,6 +248,27 @@ bool append_localized_dom_damage( right = std::max(right, bounds.x + bounds.width); bottom = std::max(bottom, bounds.y + bounds.height); }; + const auto intersects = [](const webscene_damage_rect& left, + const webscene_damage_rect& right) { + return left.x < right.x + right.width + && right.x < left.x + left.width + && left.y < right.y + right.height + && right.y < left.y + left.height; + }; + const auto include_sampled_backdrops = [&](const scene& owner, + size_t changed_index, + const webscene_damage_rect& changed_bounds) { + for (auto index = changed_index + 1U; index < owner.commands.size(); ++index) { + const auto& candidate = owner.commands[index]; + if (candidate.kind != 48U) continue; + const auto sample_bounds = command_damage_bounds( + candidate, + std::clamp(candidate.stroke_width, 0.0F, 64.0F) * 3.0F); + if (intersects(changed_bounds, sample_bounds)) { + include(candidate, candidate.stroke_width * 3.0F); + } + } + }; for (size_t index = 0; index < next.commands.size(); ++index) { const auto& old_command = previous.commands[index]; const auto& new_command = next.commands[index]; @@ -263,6 +291,14 @@ bool append_localized_dom_damage( changed = true; include(old_command, previous_blur_paddings[index]); include(new_command, next_blur_paddings[index]); + include_sampled_backdrops( + previous, + index, + command_damage_bounds(old_command, previous_blur_paddings[index])); + include_sampled_backdrops( + next, + index, + command_damage_bounds(new_command, next_blur_paddings[index])); } if (!changed) return false; 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 6891ef76..cb4d87e1 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 @@ -59,11 +59,13 @@ struct clip_scene_counts final { uint32_t blur_filter_begins{}; uint32_t functional_blur_begins{}; uint32_t linear_mask_commands{}; + uint32_t backdrop_filter_commands{}; uint32_t command_count{}; bool transform_clip_nested{}; bool compound_filter_ordered{}; bool path_clip_metadata{}; bool url_clip_metadata{}; + bool compound_backdrop_ordered{}; }; clip_scene_counts wait_for_inset_clip_scene( @@ -181,6 +183,23 @@ clip_scene_counts wait_for_inset_clip_scene( ++latest.functional_blur_begins; } else if (command.kind == 47U) { ++latest.linear_mask_commands; + } else if (command.kind == 48U) { + ++latest.backdrop_filter_commands; + if (command.flags < scene->string_count) { + const auto& resource = scene->strings[command.flags]; + if (resource.byte_offset <= scene->string_byte_count + && resource.byte_length + <= scene->string_byte_count - resource.byte_offset) { + const auto data = std::string_view( + scene->string_bytes + resource.byte_offset, + resource.byte_length); + if (data == "webscene-backdrop-v1\tblur=8;saturate=1.08" + && std::abs(command.stroke_width - 8.0F) < 0.01F + && command.rgba == 1U) { + latest.compound_backdrop_ordered = true; + } + } + } } } webscene_scene_acknowledge_v3(lease); @@ -305,6 +324,7 @@ int main() #url-clip { clip-path: url(#local-clip); } #functional-blur { filter: blur(max(4px, calc(8px * 0.25))); } #compound-filter { filter: blur(2px) saturate(1.08) contrast(1.5) grayscale(0.25); } + #compound-backdrop { -webkit-backdrop-filter: blur(8px) saturate(1.08); } #effects > span:last-child { filter: blur(2px); } `; document.head.appendChild(rules); @@ -318,6 +338,7 @@ int main() host.children[3].id = 'url-clip'; host.children[4093].id = 'functional-blur'; host.children[4094].id = 'compound-filter'; + host.children[4092].id = 'compound-backdrop'; document.body.appendChild(host); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.style.display = 'none'; @@ -357,6 +378,10 @@ int main() !== 'blur(max(4px, calc(8px * 0.25)))') { throw new Error('initial functional blur value failed'); } + if (getComputedStyle(document.getElementById('compound-backdrop')) + .getPropertyValue('backdrop-filter') !== 'blur(8px) saturate(1.08)') { + throw new Error('prefixed compound backdrop filter did not canonicalize'); + } if (getComputedStyle(document.getElementById('ellipse-clip')).getPropertyValue('clip-path') !== 'ellipse(25% 50% at 50% 50%)') { throw new Error('initial ellipse clip value failed'); @@ -449,6 +474,9 @@ int main() "retained scene did not resolve the functional blur radius"); require(initial_clip_scene.linear_mask_commands == 4096U, "retained scene did not emit all linear-gradient mask commands"); + require(initial_clip_scene.backdrop_filter_commands == 4096U + && initial_clip_scene.compound_backdrop_ordered, + "retained scene did not emit bounded authored-order backdrop filters"); const auto initial_scene_command_bytes = static_cast(initial_clip_scene.command_count) * sizeof(webscene_scene_command); @@ -584,6 +612,8 @@ int main() << " blur-filter-begins=" << initial_clip_scene.blur_filter_begins << " functional-blur-begins=" << initial_clip_scene.functional_blur_begins << " linear-mask-commands=" << initial_clip_scene.linear_mask_commands + << " backdrop-filter-commands=" << initial_clip_scene.backdrop_filter_commands + << " compound-backdrop-ordered=" << initial_clip_scene.compound_backdrop_ordered << " compound-filter-ordered=" << initial_clip_scene.compound_filter_ordered << " transform-clip-nested=" << initial_clip_scene.transform_clip_nested << " initial-scene-command-bytes=" << initial_scene_command_bytes diff --git a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs index fc35d425..1ff2b0e1 100644 --- a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs +++ b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs @@ -672,6 +672,13 @@ private static bool ValidateLayer(NativeSceneView* view, in NativeCanvasLayer la DrawDomMask(backdrop, view, command); DrawDomMask(overlay, view, command); break; + case 48: + ApplyDomBackdropFilter( + overlay, + command, + ResolveDomCornerRadii(commands, commandIndex), + DomStringAt(view, command.Flags)); + break; case 15: ApplyScale(backdrop, command); ApplyScale(overlay, command); @@ -934,6 +941,119 @@ private static void ApplyRotation(SKCanvas canvas, in SceneCommand command) canvas.Translate(-command.X, -command.Y); } + private static void ApplyDomBackdropFilter( + SKCanvas canvas, + in SceneCommand command, + in DomCornerRadii radii, + string resource) + { +#if WEBSCENE_AVALONIA12 + using var filter = CreateDomBackdropFilter(resource); + if (filter is null + || !float.IsFinite(command.X) + || !float.IsFinite(command.Y) + || !float.IsFinite(command.Width) + || !float.IsFinite(command.Height) + || command.Width <= 0 || command.Height <= 0 + || command.Width * command.Height > 67_108_864f) + { + return; + } + var save = canvas.Save(); + try + { + ClipDomRoundedRect(canvas, command, radii); + var bounds = new SKRect( + command.X, + command.Y, + command.X + command.Width, + command.Y + command.Height); + var layer = new SKCanvasSaveLayerRec + { + Bounds = bounds, + Backdrop = filter, + Flags = SKCanvasSaveLayerRecFlags.None + }; + canvas.SaveLayer(layer); + canvas.Restore(); + } + finally + { + canvas.RestoreToCount(save); + } +#endif + } + +#if WEBSCENE_AVALONIA12 + private static SKImageFilter? CreateDomBackdropFilter(string resource) + { + const string prefix = "webscene-backdrop-v1\t"; + if (!resource.StartsWith(prefix, StringComparison.Ordinal)) return null; + SKImageFilter? current = null; + var count = 0; + SKImageFilter? Reject() + { + current?.Dispose(); + current = null; + return null; + } + try + { + foreach (var component in resource[prefix.Length..].Split(';')) + { + if (++count > 16) return Reject(); + var separator = component.IndexOf('='); + if (separator <= 0 + || !float.TryParse( + component[(separator + 1)..], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var amount) + || !float.IsFinite(amount) + || amount < 0) + { + return Reject(); + } + SKImageFilter? next; + if (component.AsSpan(0, separator).Equals("blur", StringComparison.Ordinal)) + { + if (amount > 64) return Reject(); + next = SKImageFilter.CreateBlur( + amount, + amount, + SKShaderTileMode.Clamp, + current); + } + else if (component.AsSpan(0, separator).Equals("saturate", StringComparison.Ordinal)) + { + if (amount > 10) return Reject(); + var inverse = 1 - amount; + float[] matrix = [ + 0.2126f + 0.7874f * amount, 0.7152f * inverse, 0.0722f * inverse, 0, 0, + 0.2126f * inverse, 0.7152f + 0.2848f * amount, 0.0722f * inverse, 0, 0, + 0.2126f * inverse, 0.7152f * inverse, 0.0722f + 0.9278f * amount, 0, 0, + 0, 0, 0, 1, 0 + ]; + using var color = SKColorFilter.CreateColorMatrix(matrix); + next = SKImageFilter.CreateColorFilter(color, current); + } + else + { + return Reject(); + } + current?.Dispose(); + current = next; + } + return count == 0 ? null : current; + } + catch + { + current?.Dispose(); + throw; + } + } +#endif + internal readonly record struct DomCornerRadii( SKPoint TopLeft, SKPoint TopRight, diff --git a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart index d098660c..233515fb 100644 --- a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart +++ b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart @@ -52,6 +52,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { final Map _svgPictures = {}; final Map _svgMaskGeometry = {}; final List<_DomSvgPlacement> _domSvgPlacements = []; + final List<_DomBackdropEffect> _domBackdropEffects = []; ui.Picture? _backdrop; ui.Picture? _overlay; int _revision = 0; @@ -164,9 +165,19 @@ final class WebSceneSceneProjector extends ChangeNotifier { final scaleY = sourceHeight <= 0 ? 1.0 : size.height / sourceHeight; canvas.save(); canvas.scale(scaleX, scaleY); + _drawBackdropContent(canvas); + for (final effect in _domBackdropEffects) { + _drawDomBackdropEffect(canvas, effect); + } + final overlay = _overlay; + if (overlay != null) canvas.drawPicture(overlay); + _drawDomSvgPictures(canvas); + canvas.restore(); + } + + void _drawBackdropContent(ui.Canvas canvas) { final backdrop = _backdrop; if (backdrop != null) canvas.drawPicture(backdrop); - final ordered = _layers.values.toList() ..sort((left, right) => left.zOrder.compareTo(right.zOrder)); for (final layer in ordered) { @@ -195,10 +206,6 @@ final class WebSceneSceneProjector extends ChangeNotifier { ..restore() ..restore(); } - final overlay = _overlay; - if (overlay != null) canvas.drawPicture(overlay); - _drawDomSvgPictures(canvas); - canvas.restore(); } void reset() { @@ -207,6 +214,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { _backdrop = null; _overlay = null; _domSvgPlacements.clear(); + _domBackdropEffects.clear(); _svgMaskGeometry.clear(); for (final layer in _layers.values) { layer.dispose(); @@ -236,7 +244,10 @@ final class WebSceneSceneProjector extends ChangeNotifier { layer.stringCount <= scene.stringCount - layer.stringOffset; ui.Picture _compileDom(WebSceneSceneView scene, {required bool foreground}) { - if (foreground) _domSvgPlacements.clear(); + if (foreground) { + _domSvgPlacements.clear(); + _domBackdropEffects.clear(); + } final recorder = ui.PictureRecorder(); final canvas = ui.Canvas( recorder, @@ -256,6 +267,8 @@ final class WebSceneSceneProjector extends ChangeNotifier { canvas.restore(); case 47: _drawDomMask(canvas, scene, command); + case 48 when foreground: + _retainDomBackdropEffect(scene, command); case 15: canvas ..save() @@ -332,6 +345,74 @@ final class WebSceneSceneProjector extends ChangeNotifier { return recorder.endRecording(); } + void _retainDomBackdropEffect( + WebSceneSceneView scene, + WebSceneSceneCommand command, + ) { + const prefix = 'webscene-backdrop-v1\t'; + final resource = _domString(scene, command.flags); + if (!resource.startsWith(prefix) || + !command.x.isFinite || + !command.y.isFinite || + !command.width.isFinite || + !command.height.isFinite || + command.width <= 0 || + command.height <= 0 || + command.width * command.height > 67108864) { + return; + } + final operations = <_DomBackdropOperation>[]; + for (final component in resource.substring(prefix.length).split(';')) { + if (operations.length >= 16) return; + final separator = component.indexOf('='); + final amount = separator <= 0 + ? null + : double.tryParse(component.substring(separator + 1)); + if (amount == null || !amount.isFinite || amount < 0) return; + switch (component.substring(0, separator)) { + case 'blur' when amount <= 64: + operations.add(_DomBackdropOperation.blur(amount)); + case 'saturate' when amount <= 10: + operations.add(_DomBackdropOperation.saturate(amount)); + default: + return; + } + } + if (operations.isEmpty) return; + _domBackdropEffects.add(_DomBackdropEffect.fromCommand(command, operations)); + } + + void _drawDomBackdropEffect(ui.Canvas canvas, _DomBackdropEffect effect) { + canvas.save(); + canvas.clipRRect(effect.bounds, doAntiAlias: true); + final bounds = effect.bounds.outerRect; + for (final operation in effect.operations.reversed) { + final paint = ui.Paint()..blendMode = ui.BlendMode.src; + if (operation.blurSigma != null) { + paint.imageFilter = ui.ImageFilter.blur( + sigmaX: operation.blurSigma!, + sigmaY: operation.blurSigma!, + tileMode: ui.TileMode.clamp, + ); + } else { + final amount = operation.saturation!; + final inverse = 1 - amount; + paint.colorFilter = ui.ColorFilter.matrix([ + 0.2126 + 0.7874 * amount, 0.7152 * inverse, 0.0722 * inverse, 0, 0, + 0.2126 * inverse, 0.7152 + 0.2848 * amount, 0.0722 * inverse, 0, 0, + 0.2126 * inverse, 0.7152 * inverse, 0.0722 + 0.9278 * amount, 0, 0, + 0, 0, 0, 1, 0, + ]); + } + canvas.saveLayer(bounds, paint); + } + _drawBackdropContent(canvas); + for (var index = 0; index < effect.operations.length; index++) { + canvas.restore(); + } + canvas.restore(); + } + void _retainDomSvg( WebSceneSceneView scene, WebSceneSceneCommand command, @@ -1929,6 +2010,33 @@ final class WebSceneSceneProjector extends ChangeNotifier { }; } +final class _DomBackdropEffect { + _DomBackdropEffect.fromCommand( + WebSceneSceneCommand command, + this.operations, + ) : bounds = _domBackdropRRect(command); + + final ui.RRect bounds; + final List<_DomBackdropOperation> operations; + + static ui.RRect _domBackdropRRect(WebSceneSceneCommand command) => + ui.RRect.fromRectAndCorners( + ui.Rect.fromLTWH(command.x, command.y, command.width, command.height), + topLeft: ui.Radius.circular(command.radiusTopLeft), + topRight: ui.Radius.circular(command.radiusTopRight), + bottomRight: ui.Radius.circular(command.radiusBottomRight), + bottomLeft: ui.Radius.circular(command.radiusBottomLeft), + ); +} + +final class _DomBackdropOperation { + const _DomBackdropOperation.blur(this.blurSigma) : saturation = null; + const _DomBackdropOperation.saturate(this.saturation) : blurSigma = null; + + final double? blurSigma; + final double? saturation; +} + final class _SvgMaskGeometry { const _SvgMaskGeometry(this.shapes); diff --git a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs index 5e242828..e4e6cd08 100644 --- a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs +++ b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs @@ -96,6 +96,7 @@ public void DoesNotExposeUnknownOrCustomPropertiesAsIdlAttributes(string name) [InlineData("filter", "brightness(0.5) blur(2px)", true)] [InlineData("filter", "unknown(1)", false)] [InlineData("backdrop-filter", "none", true)] + [InlineData("backdrop-filter", "blur(8px) saturate(1.08)", true)] public void ValidatesCssomValuesWithoutFrameworkKnowledge(string name, string value, bool expected) => Assert.Equal(expected, CssPropertyCatalog.IsValidCssomValue(name, value)); }