WPF 从裸 Win 32 的 WM_Pointer 消息获取触摸点绘制笔迹

news/2024/11/15 19:55:56/文章来源:https://www.cnblogs.com/lindexi/p/18390983

本文将告诉大家如何在 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);}

运行代码即可看到可以在较高精度触摸屏上绘制出比较顺滑的笔迹

本文代码放在 github 和 gitee 上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快

先创建一个空文件夹,接着使用命令行 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 触摸相关技术博客,请参阅 博客导航

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/790400.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

WPF 开启Pointer消息存在的坑

本文记录在 WPF 开启 Pointer 消息的坑屏幕键盘 启用了Pointer之后,调用 TextBox.Focus() 方法时,有一定的可能起不来屏幕键盘,必须点在控件之上才行,触摸在它之上才行 后续的 Win10 版本似乎修复了这个问题,暂时还没了解到具体是从哪个版本开始修复 使用屏幕绝对坐标而不…

MIUI系统,APKMirror Installer安装apkm的时候提示app installation failed Installation aborted解决方案

场景 我的手机是MIUI系统,通过APKMirror Installer安装apkm的时候提示app installation failed Installation aborted。 本来不想装了,心想可能是版本的兼容问题,但是我查看的SDK的版本和我的android是匹配的,不应该会失败,那是为什么呢? 解决方案 禁用掉开发者选项中的启…

Serilog文档翻译系列(三) - 基础配置

Serilog基础配置:创建日志记录器、接收器、输出模板、最低级别、覆盖每个接收器、增强器、过滤器、子日志记录器 Serilog 使用简单的 C# API 来配置日志记录。当需要外部配置时,可以(慎用)通过使用 Serilog.Settings.AppSettings 包或 Serilog.Settings.Configuration 包进…

全网最适合入门的面向对象编程教程:42 Python常用复合数据类型-collections容器数据类型

在Python中,collections模块提供了一组高效、功能强大的容器数据类型,扩展了内置的基础数据类型(如list、tuple、dict等),这些容器数据类型在处理特定问题时,能够提供更简洁、更高效的解决方案。全网最适合入门的面向对象编程教程:42 Python 常用复合数据类型-collectio…

MIT6.S081(2023 Fall) Lab2 Lab3 总结

Lab1 可以说就是一些编程相关的工作,只是程序中有一些操作系统相关的概念(例如进程、管道)。做完Lab1之后我有一个问题:系统调用时如何进行的,为什么我在user下调用sleep( ),就可以直接调用到内核中的sleep代码,我并没有看到两者是如何联系的。做完Lab2,这个问题得到了…

稍微改一下 Wiki.js 的界面 CSS

Wiki.js 默认样式那个样子真的太丑了,又黑又蓝。。。反正我自己不太中意就写了覆盖样式换了 换了纯白的风格,用在自己的站了,样子见下:相关链接仓库:https://github.com/AurLemon/wikijs-citizen-styles 下载:https://github.com/AurLemon/wikijs-citizen-styles/release…

第四天---RSA进阶题型

T1.小明文攻击 一.题目: from Crypto.Util.number import * from gmpy2 import *flag = bNSSCTF{******}p = getPrime(5120) q = getPrime(5120)n = p*q e = 97 phi = (p-1)*(q-1)m = bytes_to_long(flag) c = powmod(m, e, n)print(fn = {n}) print(fe = {e}) print(fc = {c}…

电路基础 ---- 负反馈放大电路的方框图分析法

1 方框图分析法 方框图如下:图中\(A_{uo}\)是一个电压输入的放大器的放大倍数,称为开环放大倍数。 \(F\)为反馈系数,是一个矢量,是指输出信号\(x_{o}\)的多少倍回送到放大器的输入端。 \(M\)为衰减系数,也是一个矢量,是指输入信号的多少倍,进入放大器的输入端。根据上述…

抖音直播间自动发评论-网页端浏览器插件

开发了一个浏览器插件,可以实现在抖音直播间页面获取到评论 并且可以循环自动发送评论,只需要把话术列表填写上,并且点击直播间循环发送按钮,就可以自动在直播间发送了十年开发经验程序员,离职全心创业中,历时三年开发出的产品《唯一客服系统》一款基于Golang+Vue开发的在…

Docker企业级镜像仓库Harbor

Docker企业级镜像仓库Harbor 容器管理 [root@docker01 harbor]# pwd /opt/harbor [root@docker01 harbor]# docker-compose stop15.1 安装Harbor 15.1.1 安装docker、docker-compose 下载 harbor wget https://storage.googleapis.com/harbor-releases/harbor-offline-installe…

启动zabbix容器

启动zabbix容器 启动一个mysql的容器 docker run --name mysql-server -t \-e MYSQL_DATABASE="zabbix" \-e MYSQL_USER="zabbix" \-e MYSQL_PASSWORD="zabbix_pwd" \-e MYSQL_ROOT_PASSWORD="root_pwd" \-d mysql:5.7 \--character-s…