WPF 從裸 Win 32 的 WM_Pointer 訊息獲取觸控點繪製筆跡

lindexi發表於2024-09-01

本文將告訴大家如何在 WPF 裡面,接收裸 Win 32 的 WM_Pointer 訊息,從訊息裡面獲取觸控點資訊,使用觸控點資訊繪製簡單的筆跡

開始之前必須說明的是使用本文的方法不會帶來什麼優勢,既不能帶來筆跡書寫上的加速,也不能帶來筆跡效果的平滑,且程式碼複雜。本文唯一的作用只是讓大家瞭解一下基礎機制

需要再次說明的是,在 WPF 裡面,開啟了 WM_Pointer 訊息之後,透過 Touch 或 Stylus 事件收到的資訊也是從 WM_Pointer 訊息裡面過來的。大家可以嘗試在 Touch 事件監聽函式新增斷點,透過堆疊可以看到是從 Windows 訊息迴圈來的

可以從呼叫堆疊看到如下函式,此函式就是核心的 WPF 框架裡面從 WM_Pointer 訊息獲取觸控資訊的程式碼

>	PresentationCore.dll!System.Windows.Interop.HwndPointerInputProvider.System.Windows.Interop.IStylusInputProvider.FilterMessage(nint hwnd, MS.Internal.Interop.WindowMessage msg, nint wParam, nint lParam, ref bool handled)

這個 FilterMessage 函式的大概程式碼如下

	nint IStylusInputProvider.FilterMessage(nint hwnd, WindowMessage msg, nint wParam, nint lParam, ref bool handled)
	{
		handled = false;
		if (PointerLogic.IsEnabled)
		{
			switch (msg)
			{
			case WindowMessage.WM_ENABLE:
				IsWindowEnabled = MS.Win32.NativeMethods.IntPtrToInt32(wParam) == 1;
				break;
			case WindowMessage.WM_POINTERENTER:
				handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.InRange, Environment.TickCount);
				break;
			case WindowMessage.WM_POINTERUPDATE:
				handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Move, Environment.TickCount);
				break;
			case WindowMessage.WM_POINTERDOWN:
				handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Down, Environment.TickCount);
				break;
			case WindowMessage.WM_POINTERUP:
				handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.Up, Environment.TickCount);
				break;
			case WindowMessage.WM_POINTERLEAVE:
				handled = ProcessMessage(GetPointerId(wParam), RawStylusActions.OutOfRange, Environment.TickCount);
				break;
			}
		}
		return IntPtr.Zero;
	}

由此可以瞭解到,使用本文自己從 Win32 訊息獲取的觸控資訊,和從 WPF 提供的 Touch 或 Stylus 事件裡面獲取的觸控資訊的來源是相同的

這時候也許有人會說,在 WPF 裡面經過了一些封裝,可能效能不如自己寫的。我只想說,不要過於自信了哦。且別忘了訊息是從 UI 執行緒裡面獲取的,無論你用不用 WPF 的事件,在 WPF 底層的解析訊息獲取觸控資料引發事件的程式碼都會跑,也就是無論你用不用,需要 WPF 乾的活一點都沒少。只有一個 UI 執行緒的情況下,如果用自己解析的,那還會多一點點處理邏輯,完全不如直接使用 WPF 的。再加上 WPF 的解析部分沒有多少程式碼,如果有做效能分析的話,可以看到甚至做路由事件時的命中測試,判斷命中到哪個控制元件和引發事件等邏輯的耗時遠比解析來的多。且解析訊息的資料耗時接近無法被直接測量出來,即測量所需時間大於解析的效能

科普就到這裡,如果對 WPF 觸控相關感興趣,請看 WPF 觸控相關

為了能夠在訊息裡面收到 POINTER 訊息,我根據 WPF dotnet core 如何開啟 Pointer 訊息的支援 部落格提供的方法,在 App 建構函式里面新增如下程式碼開啟 Pointer 訊息的支援。本文內容裡面只給出關鍵程式碼片段,如需要全部的專案檔案,可到本文末尾找到本文所有程式碼的下載方法

    public App()
    {
        AppContext.SetSwitch("Switch.System.Windows.Input.Stylus.EnablePointerSupport", true);
    }

接下來按照 WPF 如何確定應用程式開啟了 Pointer 觸控訊息的支援 部落格提供的方法新增訊息監聽處理邏輯,如以下程式碼

    public MainWindow()
    {
        InitializeComponent();

        SourceInitialized += OnSourceInitialized;
    }

    private void OnSourceInitialized(object? sender, EventArgs e)
    {
        var windowInteropHelper = new WindowInteropHelper(this);
        var hwnd = windowInteropHelper.Handle;

        HwndSource source = HwndSource.FromHwnd(hwnd)!;
        source.AddHook(Hook);
    }

    private unsafe IntPtr Hook(IntPtr hwnd, int msg, IntPtr wparam, IntPtr lparam, ref bool handled)
    {
        ... // 忽略其他程式碼
        return IntPtr.Zero;
    }

再定義上一些訊息常量,然後跑起來程式碼確定 Pointer 訊息開啟成功

    private unsafe IntPtr Hook(IntPtr hwnd, int msg, IntPtr wparam, IntPtr lparam, ref bool handled)
    {
        const int WM_POINTERDOWN = 0x0246;
        const int WM_POINTERUPDATE = 0x0245;
        const int WM_POINTERUP = 0x0247;

        if (msg is WM_POINTERDOWN or WM_POINTERUPDATE or WM_POINTERUP)
        {
             // 在這裡打斷點,如果能進斷點則證明 Pointer 訊息開啟成功
        }

        ... // 忽略其他程式碼
        return IntPtr.Zero;
    }

以下邏輯需要呼叫一些 Win32 的 API 函式,為了方便使用,根據 dotnet 使用 CsWin32 庫簡化 Win32 函式呼叫邏輯 部落格提供的方法,使用 CsWin32 庫簡化 Win32 函式呼叫邏輯,可以減少大量的 PInvoke 定義

可以避免定義錯 PInvoke 函式導致的詭異失敗

編輯 csproj 專案檔案,替換為如下程式碼用於快速安裝 CsWin32 庫

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" Version="0.3.106" />
  </ItemGroup>
</Project>

大家可以看到以上的專案檔案程式碼的 OutputType 被我設定為 exe 型別,如此啟動專案將會有預設的控制檯,方便我在控制檯輸出內容

按照 dotnet 使用 CsWin32 庫簡化 Win32 函式呼叫邏輯 部落格提供的方法新增 NativeMethods.txt 檔案,在此檔案裡面新增一些程式碼需要用到的 Win32 函式

GetPointerTouchInfo
ScreenToClient
RegisterTouchWindow
WM_TOUCH
GetTouchInputInfo
GetPointerDeviceRects
ClientToScreen

NativeMethods.txt 檔案新增的是所需的 Win32 函式名,新增之後將會由 CsWin32 庫使用原始碼生成器方式生成對應的 PInvoke 程式碼和引數所需的型別,如結構體和列舉

根據 WPF 的原始碼,先將訊息過來的 wparam 轉換為 pointerId 引數,程式碼如下

            var pointerId = (uint) (ToInt32(wparam) & 0xFFFF);
            PInvoke.GetPointerTouchInfo(pointerId, out var info);

這裡需要額外說明的是這個 pointerId 引數不等於裝置 Id 號,即如 WPF 的 TouchDevice.Id 等,這是不相同的,需要使用 GetPointerCursorId 進行關聯才能拿到和 WPF 一樣的值。但是使用 pointerId 引數去區分不同的觸控點還是可以的

如此即可拿到核心的 POINTER_INFO 結構體物件

            POINTER_INFO pointerInfo = info.pointerInfo;

簡單處理的話,拿到的 pointerInfoptPixelLocation 欄位就是當前觸控的座標點了,採用的是畫素座標,使用螢幕座標系

            var point = pointerInfo.ptPixelLocation;

從螢幕座標系轉換為 WPF 座標系,程式碼如下

            PInvoke.ScreenToClient(new HWND(hwnd), ref point);

不考慮 DPI 的情況下,這樣就可以使用了

按照 WPF 最簡邏輯實現多指順滑的筆跡書寫 部落格提供的方法進行筆跡對接即可繪製出筆跡

這就是最簡單的從 Win32 訊息接收 Pointer 訊息繪製筆跡的方法

然而以上的方法也存在不少的問題,比如忽略了 DPI 問題,以及精度問題。在大尺寸觸控式螢幕上,直接使用 ptPixelLocation 欄位將會畫出鋸齒的筆跡。如下圖,黑色的線是直接使用 ptPixelLocation 欄位收到的觸控點連線的折線

上圖紅色的曲線是使用 WPF 記一個特別簡單的點集濾波平滑方法 部落格提供的方法進行平滑的筆跡線

在大屏觸控裝置上,從硬體層面就有一層平滑演算法了,但是受限於硬體的計算資源,只有簡單的平滑。在 Windows 的 WISPTIS 模組裡面,也會對觸控做一定的平滑演算法,如丟棄某些過於離譜的觸控點。關於 Windows 上的 WISPTIS 模組的平滑演算法屬於我和系統軟體,即軟硬體工程師,進行合作測試出來的,他輸入的點和我使用 BusHound 抓到得點和 WPF 層報告的點做對比,可以看到硬體層傳送過來的點和 BusHound 抓到的相同,而和 WPF 層報告的點大部分情況下相同,只有某些點被丟棄。被丟棄的點是我這邊設計的雜點。但是如果報告的觸控點,有瞬間飛到 0,0 點的情況,那這個 0,0 點則不會被丟棄

在 WPF 層上,從訊息到 Touch 事件這裡,是不會對點的座標進行處理,不會執行平滑演算法,最多隻有做控制元件座標轉換。在 WPF 的 Ink 模組裡面才會對輸入的點做更進一步的平滑處理

我對比了從 Pointer 訊息的 ptPixelLocation 欄位收到的觸控點對接的 WPF 最簡邏輯實現多指順滑的筆跡書寫 部落格提供的方法,和原始部落格提供的程式,可以看到還是原來的筆跡更加順滑

其核心原因在於 Pointer 訊息的 ptPixelLocation 欄位拿到的是丟失精度的點,畫素為單位。如果在精度稍微高的觸控式螢幕下,將會有明顯的鋸齒效果

如果想要獲取比較高精度的觸控點,可以使用 ptHimetricLocationRaw 欄位。這裡需要對字尾 Raw 作出更多的說明,在微軟官方文件裡面說了不帶 Raw 的是預測的值,即 ptPixelLocation 是預測的畫素座標點,而 ptPixelLocationRaw 是不帶預測的畫素座標點。對於咱如果是使用在筆跡上,其實更應該使用的是 ptPixelLocationRaw 是不帶預測的畫素座標點。否則預測效果可能會導致毛刺

使用 ptHimetricLocationRaw 欄位會稍微複雜,由於 ptHimetricLocationRaw 採用的是 pointerDeviceRect 座標系,需要轉換到螢幕座標系

轉換方法就是先將 ptHimetricLocationRaw 的 X 座標,壓縮到 [0-1] 範圍內,然後乘以 displayRect 的寬度,再加上 displayRect 的 left 值,即得到了螢幕座標系的 X 座標。壓縮到 [0-1] 範圍內的方法就是除以 pointerDeviceRect 的寬度。同理可以計算 Y 座標

以上的 displayRectpointerDeviceRect 需要使用 GetPointerDeviceRects 函式獲取

            global::Windows.Win32.Foundation.RECT pointerDeviceRect = default;
            global::Windows.Win32.Foundation.RECT displayRect = default;

            PInvoke.GetPointerDeviceRects(pointerInfo.sourceDevice, &pointerDeviceRect, &displayRect);

以上程式碼用到了不安全程式碼,記得給 Hook 函式標記上 unsafe 作為不安全程式碼

根據上文提供的演算法,編寫如下程式碼將 ptHimetricLocationRaw 轉換為 WPF 座標系的點

            // 如果想要獲取比較高精度的觸控點,可以使用 ptHimetricLocationRaw 欄位
            // 由於 ptHimetricLocationRaw 採用的是 pointerDeviceRect 座標系,需要轉換到螢幕座標系
            // 轉換方法就是先將 ptHimetricLocationRaw 的 X 座標,壓縮到 [0-1] 範圍內,然後乘以 displayRect 的寬度,再加上 displayRect 的 left 值,即得到了螢幕座標系的 X 座標。壓縮到 [0-1] 範圍內的方法就是除以 pointerDeviceRect 的寬度
            // 為什麼需要加上 displayRect.left 的值?考慮多屏的情況,螢幕可能是副屏
            // Y 座標同理
           var point2D = new Point2D(
                pointerInfo.ptHimetricLocationRaw.X / (double) pointerDeviceRect.Width * displayRect.Width +
                displayRect.left,
                pointerInfo.ptHimetricLocationRaw.Y / (double) pointerDeviceRect.Height * displayRect.Height +
                displayRect.top);

以上程式碼的 Point2D 型別的定義如下

readonly record struct Point2D(double X, double Y);

以上程式碼獲取的是螢幕座標系的點,需要轉換到 WPF 座標系

轉換過程的兩個重點:

1.底層 ClientToScreen 只支援整數型別,直接轉換會丟失精度。即使是 WPF 封裝的 PointFromScreen 或 PointToScreen 方法也會丟失精度

2.需要進行 DPI 換算,必須要求 DPI 感知

先測量視窗與螢幕的偏移量,這裡直接取 0 0 點即可,因為這裡獲取到的是虛擬螢幕座標系,不需要考慮多屏的情況

            var screenTranslate = new Point(0, 0);
            PInvoke.ClientToScreen(new HWND(hwnd), ref screenTranslate);

獲取當前的 DPI 值

            var dpi = VisualTreeHelper.GetDpi(this);

先做平移,再做 DPI 換算

            point2D = new Point2D(point2D.X - screenTranslate.X, point2D.Y - screenTranslate.Y);
            point2D = new Point2D(point2D.X / dpi.DpiScaleX, point2D.Y / dpi.DpiScaleY);

此時拿到的 point2D 就是 WPF 座標系的點了,但是拿這個點對接筆跡,如以下程式碼

            if (msg == WM_POINTERUPDATE)
            {
                var strokeVisual = GetStrokeVisual(pointerId);
                strokeVisual.Add(new StylusPoint(point2D.X, point2D.Y));
                strokeVisual.Redraw();
            }
            else if (msg == WM_POINTERUP)
            {
                StrokeVisualList.Remove(pointerId);
            }

執行程式碼即可看到可以在較高精度觸控式螢幕上繪製出比較順滑的筆跡

本文程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼。我整個程式碼倉庫比較龐大,使用以下命令列可以進行部分拉取,拉取速度比較快

先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 322313ee55d0eeaae7148b24ca279e1df087871e

以上使用的是國內的 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼。如果依然拉取不到程式碼,可以發郵件向我要程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 322313ee55d0eeaae7148b24ca279e1df087871e

獲取程式碼之後,進入 WPFDemo/DefilireceHowemdalaqu 資料夾,即可獲取到原始碼

更多 WPF 觸控相關技術部落格,請參閱 部落格導航

相關文章