大家是否好奇,在 WPF 裡面,對 UIElement 重寫 OnRender 方法進行渲染的內容,是如何受到上層容器控制元件的佈局而進行座標偏移。如有兩個放入到 StackPanel 的自定義 UIElement 控制元件,這兩個控制元件都在 OnRender 方法裡面,畫出一條從 0 到 100 的線段,此時兩個控制元件畫出的直線在窗口裡面沒有重疊。也就是說在 OnRender 裡面繪製的內容將會疊加上元素被佈局控制元件佈局的偏移的值

閱讀本文,你將瞭解佈局控制元件是如何影響到裡層控制元件的渲染,以及渲染收集過程中將會如何受到元素座標的影響

如本文開始的問題,如有兩個自定義的 UIElement 控制元件放到 StackPanel 裡面,儘管這兩個自定義的 UIElement 使用相同的程式碼繪製線段,然而在介面呈現的效果不相同。接下來本文將告訴大家在 WPF 框架是如何在佈局時影響元素渲染座標

在 WPF 裡面,最底層的介面元素是 Visual 類,在此型別上包含了一個 protected internal 訪問許可權的 VisualOffset 屬性,大概定義如下

protected internal Vector VisualOffset { set; get; }

當然了,在 WPF 框架裡面,在 VisualOffset 屬性的 set 方法上是有很多程式碼的,不過這裡面程式碼不是本文的主角,還請大家忽略

此 VisualOffset 屬性就是容器控制元件佈局的時候,將會設定元素的偏移的關鍵屬性。儘管此屬性是沒有公開的,但是咱可以通過 VisualTreeHelper 的 GetOffset 方法獲取到此屬性的值,因為 GetOffset 方法的程式碼如下

    public static class VisualTreeHelper
{
/// <summary>
/// Returns the offset of the Visual.
/// </summary>
public static Vector GetOffset(Visual reference)
{
return reference.VisualOffset;
}
}

在 UIElement 的 Arrange 方法裡面,大家都知道此方法就是用來佈局當前控制元件的。傳入的引數就是 Rect 包含了座標和尺寸,而傳入的座標將會在 UIElement 上被設定到 VisualOffset 屬性裡面,從而實現在佈局時修改元素的偏移量

大概程式碼如下

    public partial class UIElement : Visual, IInputElement, IAnimatable
{
public void Arrange(Rect finalRect)
{
// 忽略很多程式碼
ArrangeCore(finalRect);
} protected virtual void ArrangeCore(Rect finalRect)
{
VisualOffset = new Vector(finalRect.X, finalRect.Y);
}
}

通過以上程式碼可以瞭解到,實際上的元素的偏移量僅僅只是相對於上層的元素而已,也就是說 VisualOffset 存放的值是相對於上層容器的偏移量,而不是相對於視窗的偏移量

那麼此屬性是如何影響到元素的渲染的?在 Visual 型別裡面,包含了 Render 方法,這就是 Visual 在渲染收集時進入的方法。需要知道的是,呼叫 Visual 的 Render 方法和 UIElement 的 OnRender 方法是沒有直接聯絡的哦

在開始之前,先來聊聊 Visual 的 Render 方法和 UIElement 的 OnRender 方法。在 UIElement 裡面,將會在 Arrange 裡面,呼叫 OnRender 方法收集渲染的指令

    public partial class UIElement : Visual, IInputElement, IAnimatable
{
public void Arrange(Rect finalRect)
{
// 忽略很多程式碼
DrawingContext dc = RenderOpen();
OnRender(dc);
} protected virtual void OnRender(DrawingContext drawingContext)
{
} internal DrawingContext RenderOpen()
{
return new VisualDrawingContext(this);
}
}

而 Visual 的 Render 方法的呼叫堆疊是大概如下

 	PresentationCore.dll!System.Windows.Media.Visual.Render(System.Windows.Media.RenderContext ctx = {System.Windows.Media.RenderContext}, uint childIndex = 0) 行 1169	C#
PresentationCore.dll!System.Windows.Media.CompositionTarget.Compile(System.Windows.Media.Composition.DUCE.Channel channel) 行 465 C#
PresentationCore.dll!System.Windows.Media.CompositionTarget.System.Windows.Media.ICompositionTarget.Render(bool inResize, System.Windows.Media.Composition.DUCE.Channel channel) 行 346 C#
PresentationCore.dll!System.Windows.Media.MediaContext.Render(System.Windows.Media.ICompositionTarget resizedCompositionTarget = null) 行 2077 C#

依然入口在 MediaContext 的 Render 方法裡面,在這裡面將會呼叫到 Visual 的 Render 方法,此時的 Visual 的第一層就是 RootVisual 然後由 Visual 的 RenderRecursive 方法進行遞迴呼叫,讓視覺化樹上的所有 Visual 進行收集渲染

關於 MediaContext 的 Render 方法的呼叫,請看 dotnet 讀 WPF 原始碼筆記 渲染收集是如何觸發

在 Visual 的 RenderRecursive 方法裡面將會更新當前 Visual 層的偏移量,如下面程式碼

        internal void Render(RenderContext ctx, UInt32 childIndex)
{
DUCE.Channel channel = ctx.Channel;
// 在 WPF 裡面,不是所有的 Visual 都需要重新整理,只有在 Visual 存在變更的時候,影響到渲染才會重新收集
if (CheckFlagsAnd(channel, VisualProxyFlags.IsSubtreeDirtyForRender)
|| !IsOnChannel(channel))
{
RenderRecursive(ctx);
} // 忽略程式碼
} internal virtual void RenderRecursive(
RenderContext ctx)
{
DUCE.Channel channel = ctx.Channel;
DUCE.ResourceHandle handle = DUCE.ResourceHandle.Null;
VisualProxyFlags flags = VisualProxyFlags.None; bool isOnChannel = IsOnChannel(channel); UpdateCacheMode(channel, handle, flags, isOnChannel);
UpdateTransform(channel, handle, flags, isOnChannel);
UpdateClip(channel, handle, flags, isOnChannel);
UpdateOffset(channel, handle, flags, isOnChannel);
UpdateEffect(channel, handle, flags, isOnChannel);
UpdateGuidelines(channel, handle, flags, isOnChannel);
UpdateContent(ctx, flags, isOnChannel);
UpdateOpacity(channel, handle, flags, isOnChannel);
UpdateOpacityMask(channel, handle, flags, isOnChannel);
UpdateRenderOptions(channel, handle, flags, isOnChannel);
UpdateChildren(ctx, handle);
UpdateScrollableAreaClip(channel, handle, flags, isOnChannel);
} private void UpdateChildren(RenderContext ctx,
DUCE.ResourceHandle handle)
{
// 遞迴渲染所有元素
for (int i = 0; i < childCount; i++)
{
Visual child = GetVisualChild(i);
if (child != null)
{
//
// Recurse if the child visual is dirty
// or it has not been marshalled yet.
//
if (child.CheckFlagsAnd(channel, VisualProxyFlags.IsSubtreeDirtyForRender)
|| !(child.IsOnChannel(channel)))
{
child.RenderRecursive(ctx);
}
}
}
} private void UpdateOffset(DUCE.Channel channel,
DUCE.ResourceHandle handle,
VisualProxyFlags flags,
bool isOnChannel)
{
if ((flags & VisualProxyFlags.IsOffsetDirty) != 0)
{
if (isOnChannel || _offset != new Vector())
{
//
// Offset is (0, 0) by default so do not update it for new visuals.
// DUCE.CompositionNode.SetOffset(
handle,
_offset.X,
_offset.Y,
channel);
}
SetFlags(channel, false, VisualProxyFlags.IsOffsetDirty);
}
}

通過上面程式碼可以看到,在 WPF 裡面,不是所有的 Visual 都會在每次更新介面時,需要重新收集渲染資訊。只有被標記了 IsSubtreeDirtyForRender 的 Visual 才會重新收集渲染資訊。在 UpdateChildren 方法裡面將會遞迴重新整理所有的元素

在 UpdateOffset 方法將會用上 _offset 欄位,也就是 VisualOffset 屬性的欄位,相當於就在這裡獲取 VisualOffset 的值。通過上面邏輯瞭解到元素的偏移量影響到元素的渲染核心就是通過在 Visual 的 UpdateOffset 方法將元素的偏移量通過 DUCE.CompositionNode.SetOffset 方法傳入到 WPF_GFX 層,也就是實際的渲染控制層

這裡面的 CompositionNode 的 SetOffset 方法程式碼如下

            internal static void SetOffset(
DUCE.ResourceHandle hCompositionNode,
double offsetX,
double offsetY,
Channel channel)
{
DUCE.MILCMD_VISUAL_SETOFFSET command;
command.Type = MILCMD.MilCmdVisualSetOffset;
command.Handle = hCompositionNode;
command.offsetX = offsetX;
command.offsetY = offsetY; unsafe
{
channel.SendCommand(
(byte*)&command,
sizeof(DUCE.MILCMD_VISUAL_SETOFFSET)
);
}
}

實際是呼叫到 MIL 層的邏輯,以上程式碼的 hCompositionNode 表示的是在 MIL 層代表此 Visual 的指標。對應的引數將會在 MIL 層進行讀取使用,也就是說在 MIL 層將會記錄當前元素的偏移量,從而在渲染收集過程,自動給收集到的繪製指令疊加元素偏移量

在 MIL 層將會根據 command.Type = MILCMD.MilCmdVisualSetOffset; 通過一個很大的 switch 語句,進入到大概如下程式碼

    case MilCmdVisualSetOffset:
{
#ifdef DEBUG
if (cbSize != sizeof(MILCMD_VISUAL_SETOFFSET))
{
IFC(WGXERR_UCE_MALFORMEDPACKET);
}
#endif const MILCMD_VISUAL_SETOFFSET* pCmd =
reinterpret_cast<const MILCMD_VISUAL_SETOFFSET*>(pcvData); CMilVisual* pResource =
static_cast<CMilVisual*>(pHandleTable->GetResource(
pCmd->Handle,
TYPE_VISUAL
)); if (pResource == NULL)
{
RIP("Invalid resource handle.");
IFC(WGXERR_UCE_MALFORMEDPACKET);
} IFC(pResource->ProcessSetOffset(pHandleTable, pCmd));
}
break;

以上程式碼的核心是呼叫 pResource->ProcessSetOffset(pHandleTable, pCmd) 方法,而 IFC 只是一個巨集而已,用來判斷方法返回值的 HResult 是否成功

這裡的 ProcessSetOffset 方法的實現程式碼大概如下

HRESULT
CMilVisual::ProcessSetOffset(
__in_ecount(1) CMilSlaveHandleTable* pHandleTable,
__in_ecount(1) const MILCMD_VISUAL_SETOFFSET* pCmd
)
{
// The packet contains doubles. Should they be floats? Why are we using doubles in managed
// but run the compositor in floats?
float offsetX = (float)pCmd->offsetX;
float offsetY = (float)pCmd->offsetY; SetOffset(offsetX, offsetY); return S_OK;
} void
CMilVisual::SetOffset(
float offsetX,
float offsetY
)
{
// 忽略程式碼
m_offsetX = offsetX;
m_offsetY = offsetY;
} float m_offsetX;
float m_offsetY;

以上程式碼也提了一個問題,為什麼在託管層使用的是 double 型別,而在這裡使用的 float 型別。我在 GitHub 上嘗試去問問大佬們,這個是否有特別的原因,請看 Why the Visual.VisualOffset is double type but run the compositor in floats? · Issue #5389 · dotnet/wpf

太子爺: 為什麼在託管層使用的是 double 而在 MIL 層使用的是 float 型別?原因是在託管層將會用到大量的計算,此時如果使用 float 將會因為精度問題而偏差較大,如疊加很多層的佈局。但是在 MIL 層面,這是在做最終的渲染,此時使用 float 可以更好的利用顯示卡的計算資源,因為顯示卡層面對 float 的計算效率將會更高,而在這一層是最終渲染,不怕丟失精度

在 WPF 框架,將會在元素佈局的時候,也就是 UIElement 的 Arrange 方法裡面,設定 Visual 的 VisualOffset 屬性用於設定元素的偏移量,此元素偏移量是元素相對於上層容器的偏移量。此偏移量將會影響元素渲染收集過程中的繪製座標。渲染收集裡面,在 UIElement 的 OnRender 方法和 Visual 的 Render 方法之間不是順序呼叫關係,而是兩段不同的呼叫關係

將會在 UIElement 的佈局的時候,從 Arrange 呼叫到 OnRender 方法,此方法是給開發者進行重寫的,繪製開發者業務上的介面使用。此過程將是作為開發者繪製內容的渲染收集,此過程可以不在 WPF 渲染訊息觸發時被觸發,可以由開發者端發起。在 WPF 的渲染訊息進入時,將會到達 MediaContext 的 Render 方法,此方法將會層層呼叫進入 Visual 的 Render 方法,在此 Render 方法將會遞迴視覺化樹的元素進行收集渲染指令,這是應用的渲染收集過程

在 Visual 的 Render 方法裡面,將會傳輸 VisualOffset 的資料到 MIL 層,由底層控制渲染的 MIL 層使用此屬性決定渲染命令的偏移量

當前的 WPF 在 https://github.com/dotnet/wpf 完全開源,使用友好的 MIT 協議,意味著允許任何人任何組織和企業任意處置,包括使用,複製,修改,合併,發表,分發,再授權,或者銷售。在倉庫裡面包含了完全的構建邏輯,只需要本地的網路足夠好(因為需要下載一堆構建工具),即可進行本地構建

更多渲染相關部落格請看 渲染相關