1549 字
8 分钟
[WPF] 实现真正的背景模糊
2025-06-08

在 Web 中, 只需要一句 CSS 代码, 即可实现任何元素的背景模糊:

.some-class {
    backdrop-filter: blur(10px);
}

但很遗憾, WPF 并不能这样实现. 一切, 都要我们自己手搓.

我们需要什么#

要实现背景模糊, 我们首先需要得到背景, 再对其进行模糊. 假设我们有这样的一个层级关系:


 |- 某些父节点
 |   |- 某些父节点之前的兄弟
 |   |- 某些父节点之前的兄弟
 |   |- 某些父节点
 |   |   |- 某些优先绘制的兄弟
 |   |   |- 某些优先绘制的兄弟
 |   |   |- 自身
 |   |   |- 后面的兄弟
 |   |   |- 后面的兄弟...
 |   |- ...
 |- ...

那么我们的 “背景” 来自于所有 “在自己之前” 的同级节点, 自己的父节点, 然后是自己父节点之前的同级节点, 父节点的父节点, 如此递归. 直到取到根.

要获取非父节点元素的内容是非常简单的, 因为它们和我们自身是没有关系的. 我们只需要使用 VisualBrush, 就可以获取到它们的内容.

但棘手的是父节点, 因为父节点包含我们自身, 以及我们自身后面的兄弟节点. 我们需要干净的父节点自身的内容, 这是问题的关键.

所以接下来, 我们要做的是一个会绘制自身背景的元素.

WPF 的渲染流程#

我们知道, 如果要自定义一个元素的渲染, 只需要重写 OnRender 方法, 并使用传入的 DrawingContext 进行绘制即可.

通过阅读 WPF 的源代码, 我们可以发现, OnRender 的调用是在 UIElementArrange 方法中. 位于 UIElement.cs 的 928 行.

而这里的 DrawingContext 参数来自于 UIElement 的内部方法, RenderOpen 中. 它其实是创建了一个内部类 VisualDrawingContext 的实例.

在绘制完成后, 还调用了 DrawingContextClose 方法, 经过一系列调用, 最后到达了 UIElementRenderClose 方法.

RenderClose 方法本质上是将 DrawingContext 的调用指令, 存储到了 UIElement_drawingContent 字段中了.

至此, 我们可以知道, UIElement 如何呈现, 关键在于 _drawingContent 的值.

取得元素绘制内容#

通过反射, 我们可以轻易取到 UIElement_drawingContent 字段的值

private static readonly FieldInfo _drawingContentOfUIElement = typeof(UIElement)
    .GetField("_drawingContent", BindingFlags.Instance | BindingFlags.NonPublic)!;
Visual someVisual;
object drawingContent = _drawingContentOfUIElement.GetValue(someVisual);

接下来, 我们需要将其转移到一个新的 Visual 中, 然后绘制这个新的 Visual. 这里使用 DrawingVisual. 因为如果使用 UIElement, 在绘制时会调用其 OnRender 重新进行绘制, 这没意义. 而且据笔者实验, 这总是会产生一些闪烁问题.

private static readonly FieldInfo _contentOfDrawingVisual = typeof(DrawingVisual)
    .GetField("_content", BindingFlags.Instance | BindingFlags.NonPublic)!;
object drawingContentOfSomeVisual;
DrawingVisual drawingVisual = new DrawingVisual();
_contentOfDrawingVisual.SetValue(drawingVisual, drawingContentOfSomeVisual);

现在, 我们就可以通过 VisualBrush 将我们所取得的内容绘制出来了.

内容的刷新#

因为我们的内容来自父节点, 以及在自身之前的同级节点, 所以当它们位置变动, 也就是布局更新时, 我们需要重新绘制.

这很简单, 我们只需要订阅父节点作为 UIElement 的 LayoutUpdated 事件即可:

protected override void OnVisualParentChanged(DependencyObject oldParentObject)
{
    if (oldParentObject is UIElement oldParent)
    {
        oldParent.LayoutUpdated -= ParentLayoutUpdated;
    }

    if (Parent is UIElement newParent)
    {
        newParent.LayoutUpdated += ParentLayoutUpdated;
    }
}

private void ParentLayoutUpdated(object? sender, EventArgs e)
{
    // 在这里引发重绘
}

不过, 这里需要注意. 不能使用 InvalidateVisual() 引发重绘, 因为它同样会使布局失效. 这样就造成死递归了(严格来讲会导致布局在每一帧都进行, 造成卡顿).

对内容进行裁剪#

因为我们的渲染包含了背景的所有元素, 所以其大小也是和背景一致的. 它会覆盖整个窗口. 所以, 我们需要重写 GetLayoutClip 来设定呈现的范围.

protected override Geometry GetLayoutClip(Size layoutSlotSize)
{
    return new RectangleGeometry(new Rect(0, 0, ActualWidth, ActualHeight));
}

结合在一起#

所有的所有, 结合在一起, 我们得到了一个背景呈现元素, 再对其添加 BlurEffect 效果, 就是实时背景模糊:

realtime background blur effect

背景呈现元素的完整代码:

public class BackgroundPresenter : FrameworkElement
{
    private static readonly FieldInfo _drawingContentOfUIElement = typeof(UIElement)
        .GetField("_drawingContent", BindingFlags.Instance | BindingFlags.NonPublic)!;

    private static readonly FieldInfo _contentOfDrawingVisual = typeof(DrawingVisual)
        .GetField("_content", BindingFlags.Instance | BindingFlags.NonPublic)!;

    private static readonly FieldInfo _offsetOfVisual = typeof(Visual)
        .GetField("_offset", BindingFlags.Instance | BindingFlags.NonPublic)!;

    private static readonly Func<UIElement, DrawingContext> _renderOpenMethod = typeof(UIElement)
        .GetMethod("RenderOpen", BindingFlags.Instance | BindingFlags.NonPublic)!
        .CreateDelegate<Func<UIElement, DrawingContext>>();

    private static readonly Action<UIElement, DrawingContext> _onRenderMethod = typeof(UIElement)
        .GetMethod("OnRender", BindingFlags.Instance | BindingFlags.NonPublic)!
        .CreateDelegate<Action<UIElement, DrawingContext>>();

    private static readonly GetContentBoundsDelegate _methodGetContentBounds = typeof(VisualBrush)
        .GetMethod("GetContentBounds", BindingFlags.Instance | BindingFlags.NonPublic)!
        .CreateDelegate<GetContentBoundsDelegate>();

    private delegate void GetContentBoundsDelegate(VisualBrush visualBrush, out Rect bounds);
    private readonly Stack<UIElement> _parentStack = new();

    private static void ForceRender(UIElement target)
    {
        using DrawingContext drawingContext = _renderOpenMethod(target);

        _onRenderMethod.Invoke(target, drawingContext);
    }

    private static void DrawVisual(DrawingContext drawingContext, Visual visual, Point relatedXY, Size renderSize)
    {
        var visualBrush = new VisualBrush(visual);
        var visualOffset = (Vector)_offsetOfVisual.GetValue(visual)!;

        _methodGetContentBounds.Invoke(visualBrush, out var contentBounds);
        relatedXY -= visualOffset;

        drawingContext.DrawRectangle(
            visualBrush, null,
            new Rect(relatedXY.X + contentBounds.X, relatedXY.Y + contentBounds.Y, contentBounds.Width, contentBounds.Height));
    }

    protected override Geometry GetLayoutClip(Size layoutSlotSize)
    {
        return new RectangleGeometry(new Rect(0, 0, ActualWidth, ActualHeight));
    }

    protected override void OnVisualParentChanged(DependencyObject oldParentObject)
    {
        if (oldParentObject is UIElement oldParent)
        {
            oldParent.LayoutUpdated -= ParentLayoutUpdated;
        }

        if (Parent is UIElement newParent)
        {
            newParent.LayoutUpdated += ParentLayoutUpdated;
        }
    }

    private void ParentLayoutUpdated(object? sender, EventArgs e)
    {
        // cannot use 'InvalidateVisual' here, because it will cause infinite loop

        ForceRender(this);

        Debug.WriteLine("Parent layout updated, forcing render of BackgroundPresenter.");
    }

    private static void DrawBackground(
        DrawingContext drawingContext, UIElement self,
        Stack<UIElement> parentStackStorage,
        int maxDepth,
        bool throwExceptionIfParentArranging)
    {
#if DEBUG
        bool selfInDesignMode = DesignerProperties.GetIsInDesignMode(self);
#endif

        var parent = VisualTreeHelper.GetParent(self) as UIElement;
        while (
            parent is { } &&
            parentStackStorage.Count < maxDepth)
        {
            // parent not visible, no need to render
            if (!parent.IsVisible)
            {
                parentStackStorage.Clear();
                return;
            }

#if DEBUG
            if (selfInDesignMode &&
                parent.GetType().ToString().Contains("VisualStudio"))
            {
                // 遍历到 VS 自身的设计器元素, 中断!
                break;
            }
#endif

            // is parent arranging
            // we cannot render it
            if (parent.RenderSize.Width == 0 ||
                parent.RenderSize.Height == 0)
            {
                parentStackStorage.Clear();

                if (throwExceptionIfParentArranging)
                {
                    throw new InvalidOperationException("Arranging");
                }

                // render after parent arranging finished
                self.InvalidateArrange();
                return;
            }

            parentStackStorage.Push(parent);
            parent = VisualTreeHelper.GetParent(parent) as UIElement;
        }

        var selfRect = new Rect(0, 0, self.RenderSize.Width, self.RenderSize.Height);
        while (parentStackStorage.TryPop(out var currentParent))
        {
            if (!parentStackStorage.TryPeek(out var breakElement))
            {
                breakElement = self;
            }

            var parentRelatedXY = currentParent.TranslatePoint(default, self);

            // has render data
            if (_drawingContentOfUIElement.GetValue(currentParent) is { } parentDrawingContent)
            {
                var drawingVisual = new DrawingVisual();
                _contentOfDrawingVisual.SetValue(drawingVisual, parentDrawingContent);

                DrawVisual(drawingContext, drawingVisual, parentRelatedXY, currentParent.RenderSize);
            }

            if (currentParent is Panel parentPanelToRender)
            {
                foreach (UIElement child in parentPanelToRender.Children)
                {
                    if (child == breakElement)
                    {
                        break;
                    }

                    var childRelatedXY = child.TranslatePoint(default, self);
                    var childRect = new Rect(childRelatedXY, child.RenderSize);

                    if (!selfRect.IntersectsWith(childRect))
                    {
                        continue; // skip if not intersecting
                    }

                    if (child.IsVisible)
                    {
                        DrawVisual(drawingContext, child, childRelatedXY, child.RenderSize);
                    }
                }
            }
        }
    }

    public static void DrawBackground(DrawingContext drawingContext, UIElement self)
    {
        var parentStack = new Stack<UIElement>();
        DrawBackground(drawingContext, self, parentStack, int.MaxValue, true);
    }

    protected override void OnRender(DrawingContext drawingContext)
    {
        DrawBackground(drawingContext, this, _parentStack, MaxDepth, false);
    }

    public int MaxDepth
    {
        get { return (int)GetValue(MaxDepthProperty); }
        set { SetValue(MaxDepthProperty, value); }
    }

    public static readonly DependencyProperty MaxDepthProperty =
        DependencyProperty.Register("MaxDepth", typeof(int), typeof(BackgroundPresenter), new PropertyMetadata(64));
}

仓库#

完整的测试代码仓库如下:

SlimeNull
/
BlurBehindTest
Waiting for api.github.com...
00K
0K
0K
Waiting...

在之后, 我打算将其封装成更多易用的控件, 并添加到 WPF Suite 中. 敬请期待!

OrgEleCho
/
EleCho.WpfSuite
Waiting for api.github.com...
00K
0K
0K
Waiting...
[WPF] 实现真正的背景模糊
https://slimenull.com/posts/20250608020400/
作者
SlimeNull
发布于
2025-06-08
许可协议
CC BY-NC-SA 4.0