尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity调用Win32 API实现窗口贴边检测与胶卷显形特效

Unity调用Win32 API实现窗口贴边检测与胶卷显形特效 1. 项目概述与核心需求解析最近在复刻《Oneshot》这款游戏里一个非常经典的“胶卷显形”效果时遇到了一个挺有意思的技术点想和大家分享一下。这个效果简单来说就是当游戏窗口被拖到屏幕底部边缘时窗口内的画面会像冲洗胶卷一样逐渐显露出隐藏的内容。听起来很酷对吧但实现它的第一步就卡在了如何精确获取和控制游戏窗口的位置和状态上。Unity 自带的Screen和Application类在处理多显示器、任务栏等系统级UI时信息粒度不够细尤其是想判断窗口是否“贴”到了屏幕的物理底边而不是任务栏的顶边用纯 Unity API 就比较棘手了。这就引出了我们今天的主题在 Unity 中结合 Win32 API 来实现精细化的窗口控制从而为“胶卷显形”这类需要与操作系统桌面环境深度交互的效果铺平道路。这个需求本质上是一个“跨界”问题。Unity 作为一个跨平台的游戏引擎其设计初衷是抽象掉底层操作系统的差异提供一套统一的接口。这在大方向上无疑是正确的但当我们想要实现一些高度依赖特定操作系统特性比如 Windows 的窗口消息机制、精确的屏幕坐标系统的功能时这套抽象层就显得有些“力不从心”了。因此我们需要直接调用 Windows 平台的原生 API也就是 Win32 API来获取我们需要的精确控制权。这不仅仅是实现一个游戏特效更是理解 Unity 如何与宿主操作系统协同工作的一个绝佳案例。无论是想做游戏桌面工具、实现特殊的窗口化效果还是进行深度的性能监控和调试掌握这套方法都大有裨益。2. 技术选型为什么是 Win32 API 与 P/Invoke面对获取精确窗口位置的需求我们有几个备选方案。最直观的可能是利用 Unity 的PlayerSettings设置一个无边框窗口然后自己处理拖动。但这无法解决判断窗口与屏幕物理边缘关系的问题因为 Unity 无此接口。另一个思路是使用 .NET 自身的System.Windows.Forms或System.Windows命名空间但在 Unity 的运行时环境下尤其是较新版本使用 .NET Standard 或 .NET Core 适配层时这些库的可用性和行为并不稳定容易引发兼容性问题。因此直接调用 Win32 API 成为了最可靠、最直接的选择。Win32 API 是 Windows 操作系统的核心编程接口提供了对窗口、消息、图形设备等最底层的控制能力。在 C# 中调用 Win32 API需要通过一种称为“平台调用”Platform Invocation Services, P/Invoke的技术。P/Invoke 允许托管代码如我们的 C# 脚本调用位于非托管 DLL如user32.dll,kernel32.dll中的函数。对于 Unity 项目而言这意味着我们可以在 MonoBehaviour 脚本中直接声明这些外部函数然后像调用普通 C# 方法一样使用它们。选择此方案的核心优势在于精准与稳定。我们可以获得窗口在虚拟屏幕坐标系中的精确位置包括多显示器场景可以接收到系统发送给窗口的原始消息如移动、大小改变并且其行为与 Windows 系统本身完全一致不受 Unity 版本或 .NET 后端差异的影响。当然它的代价是失去了跨平台性——这段代码只能在 Windows 平台的 Unity 播放器或构建出的游戏中运行。对于《Oneshot》这类主要面向 PC 平台的游戏或者我们明确针对 Windows 环境开发的功能这个代价是可以接受的。在代码中我们需要通过#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN这样的编译指令来确保代码仅在 Windows 平台被编译和执行从而保持项目在其他平台如 Mac、Linux的可编译性。3. 核心 Win32 API 函数解析与封装要实现窗口位置监控我们需要用到几个关键的 Win32 API 函数。首先我们需要获取当前游戏窗口的句柄Handle。在 Windows 中每个窗口都有一个唯一的句柄HWND所有针对该窗口的操作都需要通过这个句柄进行。Unity 并没有直接暴露这个句柄但我们可以通过FindWindow这个 API 来查找它。[DllImport(user32.dll, SetLastError true)] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);通常我们可以通过应用程序的标题窗口名称来查找。Unity 游戏窗口的默认标题是产品名称PlayerSettings 中的 ProductName在编辑器状态下则是“Unity [版本号]”。获取到句柄后我们就可以调用GetWindowRect函数来取得窗口的矩形区域。[DllImport(user32.dll)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); // 定义一个与 Win32 RECT 结构体对应的 C# 结构体 [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; // 为了方便可以添加属性来获取宽度和高度 public int Width Right - Left; public int Height Bottom - Top; }GetWindowRect返回的矩形坐标是相对于“虚拟屏幕”的。虚拟屏幕是一个包含所有显示器拼接起来的全局坐标系主显示器的左上角通常是 (0, 0)。Left和Top是窗口左上角的坐标Right和Bottom是窗口右下角的坐标。这里有一个非常重要的细节这个矩形包含了窗口的边框和非客户区。也就是说如果你有一个带边框的窗口Top的值可能比窗口内客户区Client Area的实际顶部要小因为包含了标题栏的高度。对于我们的“贴边检测”我们关心的是窗口整体的外轮廓位置所以使用这个矩形是合适的。接下来我们需要知道屏幕的工作区Work Area。工作区是指屏幕扣除任务栏、Dock 等系统工具栏之后的可用于放置窗口的区域。Unity 的Screen.currentResolution获取的是整个屏幕的分辨率而Screen类的一些其他属性在多个显示器下行为可能不符合预期。为了精确我们同样使用 Win32 APISystemParametersInfo函数来获取主显示器的工作区或者MonitorFromWindow和GetMonitorInfo来获取窗口所在显示器的工作区。后者在多显示器环境下更准确。[DllImport(user32.dll)] public static extern bool SystemParametersInfo(uint uiAction, uint uiParam, ref RECT pvParam, uint fWinIni); // 用于获取主显示器工作区 public const uint SPI_GETWORKAREA 0x0030; [DllImport(user32.dll)] public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags); [DllImport(user32.dll)] public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi); [StructLayout(LayoutKind.Sequential)] public struct MONITORINFO { public int cbSize; public RECT rcMonitor; // 整个显示器的范围 public RECT rcWork; // 显示器的工作区范围 public uint dwFlags; }注意在声明这些外部函数和结构体时务必确保参数类型、调用约定默认为StdCall与 Win32 API 文档一致。结构体的大小cbSize必须在调用前正确赋值通常为Marshal.SizeOf(typeof(MONITORINFO))。不正确的封装是导致 P/Invoke 调用失败或程序崩溃的最常见原因。有了窗口矩形windowRect和屏幕工作区矩形screenWorkArea判断窗口是否移动到了屏幕底部边缘就变得非常简单检查windowRect.Bottom是否大于等于screenWorkArea.Bottom。这里用“大于等于”而不是“等于”是因为窗口拖动时可能稍微越过边界一点我们需要一个容差范围来确保检测的灵敏度和用户体验。4. Unity 中的实时窗口位置监控实现在 Unity 中我们不能像在传统的 WinForms 或 WPF 应用中那样直接订阅窗口消息循环。我们需要在 MonoBehaviour 的Update()或LateUpdate()循环中主动去“轮询”窗口的位置状态。虽然轮询不如事件驱动高效但对于每秒数十次更新的游戏循环来说检测一次窗口位置的开销是微不足道的。首先我们需要在脚本初始化时如Awake或Start方法中获取到游戏窗口的句柄。一个健壮的做法是在Start方法中启动一个协程Coroutine循环尝试查找窗口直到成功为止因为窗口创建可能需要一两帧的时间。private IntPtr _windowHandle IntPtr.Zero; private const string WindowTitle “YourGameName”; // 应与PlayerSettings中一致 IEnumerator FindGameWindow() { while (_windowHandle IntPtr.Zero) { _windowHandle Win32API.FindWindow(null, WindowTitle); if (_windowHandle IntPtr.Zero) { // 在编辑器中窗口标题可能不同可以尝试带“Unity”的标题 #if UNITY_EDITOR _windowHandle Win32API.FindWindow(null, “Unity*”); #endif yield return new WaitForSeconds(0.1f); // 等待0.1秒再试 } else { Debug.Log($“成功找到窗口句柄: {_windowHandle}”); break; } } }获取句柄后在Update方法中我们就可以定期调用GetWindowRect和GetMonitorInfo来更新窗口位置和屏幕信息。void Update() { if (_windowHandle IntPtr.Zero) return; // 1. 获取窗口矩形 Win32API.RECT windowRect; if (!Win32API.GetWindowRect(_windowHandle, out windowRect)) { Debug.LogError(“获取窗口矩形失败”); return; } // 2. 获取窗口所在显示器的工作区 IntPtr monitorHandle Win32API.MonitorFromWindow(_windowHandle, 2 /*MONITOR_DEFAULTTONEAREST*/); Win32API.MONITORINFO monitorInfo new Win32API.MONITORINFO(); monitorInfo.cbSize Marshal.SizeOf(typeof(Win32API.MONITORINFO)); if (!Win32API.GetMonitorInfo(monitorHandle, ref monitorInfo)) { // 如果获取失败回退到主显示器工作区 Win32API.RECT workArea new Win32API.RECT(); Win32API.SystemParametersInfo(Win32API.SPI_GETWORKAREA, 0, ref workArea, 0); monitorInfo.rcWork workArea; } // 3. 判断是否触及底部边缘 // 设置一个容差比如5像素这样窗口底部距离屏幕底部5像素以内就认为“贴边” const int tolerance 5; bool isAtBottom windowRect.Bottom (monitorInfo.rcWork.Bottom - tolerance); // 4. 触发胶卷显形逻辑 if (isAtBottom !_wasAtBottomLastFrame) { OnWindowReachedBottom(); } _wasAtBottomLastFrame isAtBottom; }这里有一个关键点我们判断的是windowRect.Bottom和monitorInfo.rcWork.Bottom的关系而不是monitorInfo.rcMonitor.Bottom。rcMonitor是整个显示器的物理范围rcWork是扣除任务栏后的可用区域。如果用户的任务栏是自动隐藏的那么rcWork和rcMonitor是相等的。使用rcWork可以确保我们的效果在任务栏可见时只在窗口真正拖到任务栏“后面”时才触发这符合《Oneshot》原版游戏的体验也更符合用户直觉——窗口贴住任务栏上边缘时并不算“到达屏幕底部”。5. “胶卷显形”效果的核心逻辑与 Shader 实现当检测到窗口到达屏幕底部后就该触发核心的视觉效果了。在《Oneshot》中这个效果表现为游戏画面像一张浸泡在显影液中的相纸影像从底部逐渐向上浮现。在 Unity 中实现这种全屏后处理效果最优雅和高效的方式是使用一个自定义的 Image Effect Shader在后渲染管线中或者一个全屏的 Render Feature在 URP/HDRP 中。这里我们以传统的 Image Effect 方式为例因为它概念更直接兼容性更广。其核心思路是在 OnRenderImage 函数中我们将源渲染纹理source使用一个材质Material进行二次处理然后输出到目标纹理destination。我们的 Shader 将根据一个动态的“显影进度”值来控制画面不同部分的显示。首先我们需要一个脚本来控制这个进度值并将其传递给 Shader。public class FilmRevealEffect : MonoBehaviour { public Shader filmRevealShader; private Material _material; [Range(0, 1)] public float revealProgress 0.0f; // 0为完全隐藏1为完全显示 public float revealSpeed 0.5f; private bool _isRevealing false; void OnEnable() { if (filmRevealShader null || !filmRevealShader.isSupported) { enabled false; return; } _material new Material(filmRevealShader); _material.hideFlags HideFlags.HideAndDontSave; } void OnDisable() { if (_material ! null) DestroyImmediate(_material); } // 由窗口监控脚本调用 public void StartReveal() { _isRevealing true; } void Update() { if (_isRevealing revealProgress 1.0f) { revealProgress revealSpeed * Time.deltaTime; revealProgress Mathf.Clamp01(revealProgress); } } // 这是关键的后处理函数 void OnRenderImage(RenderTexture source, RenderTexture destination) { if (_material ! null revealProgress 0.999f) { _material.SetFloat(“_RevealProgress”, revealProgress); // 可以传递一些随机噪声纹理来模拟胶卷颗粒感 Graphics.Blit(source, destination, _material); } else { // 如果效果已完成或材质无效直接拷贝 Graphics.Blit(source, destination); } } }接下来是 Shader 部分。这个 Shader 的核心是片段着色器Fragment Shader。我们需要根据当前像素的屏幕坐标V方向即上下方向和_RevealProgress值来决定其最终颜色。Shader “Hidden/FilmReveal” { Properties { _MainTex (“Texture”, 2D) “white” {} _RevealProgress (“Reveal Progress”, Range(0, 1)) 0.0 _NoiseTex (“Noise Texture”, 2D) “white” {} _GrainIntensity (“Grain Intensity”, Range(0, 1)) 0.1 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include “UnityCG.cginc” struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } sampler2D _MainTex; sampler2D _NoiseTex; float _RevealProgress; float _GrainIntensity; fixed4 frag (v2f i) : SV_Target { // 基础颜色 fixed4 col tex2D(_MainTex, i.uv); // 核心逻辑从下往上的显影 // i.uv.y 范围是0底部到1顶部 // 当_RevealProgress为0时threshold为1只有uv.y 1的像素不存在才显示所以全黑。 // 随着_RevealProgress增加threshold降低更多底部像素开始显示。 float threshold 1.0 - _RevealProgress; float revealFactor step(threshold, i.uv.y); // 添加一些噪声来模拟胶卷颗粒噪声强度在显影边缘最强 float2 noiseUV i.uv * float2(10.0, 10.0) _Time.y * 0.1; // 让噪声动起来 float noise tex2D(_NoiseTex, noiseUV).r * 2.0 - 1.0; // 映射到[-1, 1] // 边缘检测计算当前uv.y与threshold的接近程度 float edge smoothstep(threshold - 0.05, threshold 0.05, i.uv.y); float grain noise * _GrainIntensity * (1.0 - edge); // 在边缘处减弱颗粒感 // 应用显影因子和颗粒 col.rgb col.rgb * revealFactor grain; // 对于未显影的区域可以染上一点暗房的红褐色调 col.rgb lerp(col.rgb * fixed3(0.4, 0.2, 0.1), col.rgb, revealFactor); // 可选在显影边缘添加一条微弱的亮边模拟化学显影的边界 float edgeGlow smoothstep(threshold - 0.01, threshold, i.uv.y) * (1.0 - smoothstep(threshold, threshold 0.01, i.uv.y)); col.rgb edgeGlow * fixed3(0.8, 0.7, 0.5) * 0.3; return col; } ENDCG } } }这个 Shader 的关键在于step(threshold, i.uv.y)这一行。它创建了一个硬边缘的遮罩threshold值随着_RevealProgress从1向0变化遮罩的可见区域就从屏幕底部逐渐向上推进。我们使用smoothstep来软化这个边缘并添加了动态噪声和颜色偏移来模拟胶卷的化学显影过程让效果更加生动和有机而不是一个生硬的渐变。6. 系统集成与性能优化要点将窗口监控与视觉效果脚本整合起来就完成了核心功能。窗口监控脚本在检测到贴底事件时调用FilmRevealEffect脚本的StartReveal()方法。但是在实际项目中我们还需要考虑更多细节。性能方面虽然 Win32 API 的调用和简单的矩形比较开销很小但在Update中每帧调用GetWindowRect和GetMonitorInfo仍是不必要的。一个优化方案是只在窗口可能移动的时候进行检测。我们可以通过SetWindowsHookEx来安装一个低级鼠标钩子监听鼠标左键按下和移动事件当检测到鼠标在窗口标题栏区域通过SendMessage发送WM_NCHITTEST消息可以判断按下并移动时再开始高频率的位置轮询当鼠标释放时停止轮询或降低频率。这能显著减少无操作时的 CPU 占用。不过钩子编程更为复杂且需要处理消息循环对于初学者每帧检测在性能可接受范围内通常远低于 0.01ms可以作为起点。多显示器与 DPI 缩放是现代 Windows 桌面环境必须考虑的问题。Win32 API 的GetWindowRect返回的是“物理像素”坐标而 Unity 的屏幕坐标和 UI 系统可能受 DPI 虚拟化影响。在大多数情况下我们的贴边检测使用物理像素坐标是准确的。但是如果你需要将窗口位置信息反馈给 Unity 内部的 UI比如在游戏内画一个窗口位置的指示器就需要进行坐标转换。可以使用GetDpiForWindow函数获取窗口的 DPI然后进行缩放计算。更复杂的是不同显示器可能有不同的 DPI 缩放比例MonitorFromWindow和GetMonitorInfo返回的矩形坐标已经考虑了当前显示器的 DPI通常能与GetWindowRect的结果正确对应。编辑器与发布版的差异需要特别注意。在 Unity 编辑器中运行游戏时游戏视图是嵌入在 Unity 编辑器窗口中的一个面板它没有独立的 Win32 窗口句柄。我们之前通过查找“Unity*”标题找到的是编辑器主窗口的句柄。因此在编辑器中“贴边检测”检测的是整个 Unity 编辑器窗口是否贴边这通常不是我们想要测试的。为了获得更好的开发体验可以创建两个模式在编辑器中我们用一个简单的 UI 滑块来模拟_RevealProgress或者通过一个快捷键来手动触发效果从而绕过窗口检测逻辑。这可以通过#if UNITY_EDITOR预处理指令来实现。void Update() { #if UNITY_EDITOR // 编辑器模式下用空格键模拟触发 if (Input.GetKeyDown(KeyCode.Space)) { filmRevealEffect.StartReveal(); } #else // 发布版逻辑实时窗口检测 // ... 原有的窗口检测代码 ... #endif }错误处理与健壮性也至关重要。P/Invoke 调用可能失败返回false或IntPtr.Zero。我们应该检查每次 API 调用的返回值并使用Marshal.GetLastWin32Error()获取错误代码以便在调试时快速定位问题。此外窗口句柄可能在游戏运行期间发生变化虽然极少见因此最好在每次检测前验证句柄的有效性或者监听系统的窗口销毁/创建消息但这又涉及到更复杂的窗口过程Window Procedure钩子超出了基础需求的范畴。一个折中的办法是在每次检测失败后尝试重新查找一次窗口句柄。7. 常见问题排查与调试技巧实录在实际开发中你几乎一定会遇到一些问题。下面是我在实现过程中踩过的一些坑和对应的解决方案希望能帮你节省时间。问题一FindWindow返回IntPtr.Zero找不到窗口句柄。可能原因1窗口标题不匹配。Unity 构建的游戏其窗口标题默认是PlayerSettings-Product Name。请确保你传入FindWindow的lpWindowName参数与之一致。一个调试技巧是先构建一个空的 exe运行它然后用 SpyVisual Studio 工具或类似工具查看其准确的窗口标题和类名。可能原因2窗口尚未创建。在Awake或过早的Start中调用窗口可能还没准备好。使用协程延迟查找是更可靠的方法。解决方案使用FindWindow时可以将lpClassName参数设为null只通过标题查找。如果知道窗口类名Unity 独立播放器通常是UnityWndClass也可以同时指定提高准确性。问题二GetWindowRect获取的位置坐标看起来不对比如是负数或巨大的值。可能原因在多显示器设置中主显示器的左上角是 (0,0)位于主显示器左侧的显示器其坐标的 X 值可能为负数。这是正常的虚拟屏幕坐标系。你的windowRect.Left为负仅仅意味着窗口有一部分或全部在第二个显示器上。确保你的屏幕工作区矩形rcWork是通过MonitorFromWindow获取的与窗口对应的那个显示器信息而不是主显示器。问题三贴边检测不灵敏或过于灵敏。可能原因容差tolerance值设置不当。如果容差太小用户需要非常精确地将窗口拖到底部边缘才能触发如果容差太大窗口还没完全拖下去就触发了体验很奇怪。解决方案将容差值设置为一个合理的像素数比如 5 到 10 像素。你可以将其暴露为脚本的公共变量方便在 Unity 编辑器中调试和调整。bool isAtBottom windowRect.Bottom (monitorInfo.rcWork.Bottom - tolerance);问题四在带有透明或异形窗口的游戏上效果异常。可能原因GetWindowRect获取的是窗口的外边框矩形。如果你设置了无边框窗口borderless或者自定义了窗口形状这个矩形可能仍然包含一些不可见的系统装饰区域虽然无边框下可能为0。这通常不影响“贴底”检测因为用户拖动的是整个窗口区域。但如果你的游戏客户区Client Rect与外边框有较大偏移可能需要使用GetClientRect和ClientToScreen来转换坐标但这对于贴边检测通常不是必须的。问题五Shader 效果没有显示或者屏幕全黑/全白。排查步骤检查 Material 是否创建成功在OnEnable中打印_material是否为空检查filmRevealShader是否赋值且支持。检查_RevealProgress值在Update中打印其值确认它是否在 0 到 1 之间变化。简化 Shader先将 Shader 内容简化例如直接返回fixed4(i.uv.x, i.uv.y, 0, 1)来测试 UV 是否正确。然后逐步添加显影逻辑。检查渲染顺序确保FilmRevealEffect脚本挂在 Camera 对象上并且是最后一个执行的后期处理脚本如果有多个。问题六构建后游戏崩溃。最可能原因P/Invoke 函数或结构体声明错误。例如DllImport中的函数名大小写错误、参数类型不匹配、结构体字段顺序或大小不对。调试方法在编辑器模式下大量测试因为大部分 P/Invoke 错误在编辑器下也会以异常形式抛出。仔细核对 MSDN 上相关 API 的签名。确保使用了SetLastError true并在调用后检查错误码。一个非常实用的调试技巧是在游戏运行时在屏幕上实时绘制出我们获取到的窗口矩形和屏幕工作区矩形的信息。可以在OnGUI函数中将windowRect和monitorInfo.rcWork的Left, Top, Right, Bottom值打印出来。这样当你拖动窗口时就能直观地看到这些数值如何变化以及它们之间的关系是否正确这对于验证你的检测逻辑至关重要。
返回列表