1. 项目概述为什么PC端无边框窗口是个“技术活”如果你用Unity开发过PC平台的游戏或应用大概率遇到过这样的需求想要一个沉浸感十足、没有标题栏和边框的窗口但又不想让应用完全“霸占”屏幕用户还得能随时切出去回个消息、看看任务栏。这个看似简单的“无边框窗口保留任务栏”功能在Unity里实现起来可不像在编辑器里勾选一个“Fullscreen Mode”那么简单。它背后涉及到的是Windows或macOS/Linux原生窗口系统与Unity渲染引擎之间一场精密的“握手协议”。我接手过好几个项目从独立游戏到专业工具软件都卡在这个需求上。美术和策划想要极致的视觉沉浸而程序则要兼顾用户的系统交互习惯和应用的稳定性。Unity内置的FullScreenMode无论是Exclusive FullScreen还是Borderless Window都难以完美平衡这两点独占全屏会接管整个显示输出导致AltTab切换卡顿甚至黑屏且完全隐藏任务栏而无边框窗口模式虽然保留了任务栏但窗口位置和大小管理、多显示器适配、以及某些系统特性如Windows 11的Snap Layout的支持往往不尽如人意。所以我们需要自己动手深入到平台原生API的层面去“雕刻”出我们想要的窗口形态。这不仅仅是调用一两个函数而是理解Unity的渲染循环如何与操作系统的窗口消息泵协同工作。接下来我会拆解整个技术方案从设计思路到每一行关键代码并分享我趟过的坑和总结的实战技巧。2. 核心思路在“全屏”与“窗口”之间找到平衡点我们的目标很明确创建一个视觉上铺满整个屏幕的“窗口”但这个窗口没有标题栏、边框和系统菜单同时操作系统的任务栏以及其他系统UI元素如通知中心必须保持可见和可交互。2.1 方案选型为什么不用Unity内置选项首先我们排除掉Unity内置的简单选项Screen.fullScreen true(独占全屏)此模式下Unity应用程序直接控制整个显示器的帧缓冲区。任务栏、AltTab界面等都会被完全隐藏。切换延迟高在多显示器环境下行为复杂且容易引发驱动兼容性问题。不适合需要频繁系统交互的应用。Screen.fullScreenMode FullScreenMode.FullScreenWindow(无边框窗口全屏)这是Unity提供的“无边框全屏窗口”。它确实创建了一个无边框、铺满屏幕的窗口。但是在多数Windows版本上这个窗口会覆盖在任务栏之上导致任务栏被遮挡。用户必须使用Win键或CtrlEsc才能唤出开始菜单体验割裂。因此我们必须寻求平台原生API的帮助。对于Windows平台这也是PC端最主要的平台这意味着使用user32.dll中的函数来直接操作窗口样式和位置。2.2 技术原理窗口样式与位置的魔法Windows的每个窗口都有一系列样式Window Styles例如WS_CAPTION标题栏、WS_THICKFRAME可调整大小的边框、WS_SYSMENU系统菜单。要实现无边框核心就是移除这些样式。但同时我们还需要保留窗口的某些特性使其仍然被系统识别为一个顶级窗口并能与任务栏正常交互。这里的关键是移除视觉边框去掉WS_CAPTION,WS_THICKFRAME等。保留任务栏交互确保窗口不是“顶层”窗口WS_EX_TOPMOST在某些情况下需谨慎使用并且其Z序窗口叠放顺序正常。精准控制窗口位置和大小将窗口的位置(0, 0)和尺寸设置为与屏幕的工作区Work Area或整个屏幕Screen一致。“工作区”是指屏幕减去任务栏占用的区域这正是我们想要的效果——窗口填满任务栏之外的所有空间。整个流程可以概括为在Unity窗口创建后立即获取其原生窗口句柄HWND然后通过P/Invoke调用user32.dll的函数修改其样式和尺寸最后每帧或响应屏幕分辨率变化时确保其状态正确。3. 实战使用Windows API实现无边框任务栏窗口我们将创建一个名为BorderlessWindowController的C#脚本来完成所有工作。这个脚本应该附加在场景中一个永不销毁的GameObject上如GameManager。3.1 第一步声明所需的Windows API函数与常量首先我们需要在C#中声明将要使用的Win32 API函数和窗口样式常量。using System; using System.Runtime.InteropServices; using UnityEngine; public class BorderlessWindowController : MonoBehaviour { // 引入必要的Win32 API函数 [DllImport(user32.dll)] private static extern IntPtr GetActiveWindow(); [DllImport(user32.dll)] private static extern bool SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); [DllImport(user32.dll)] private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); [DllImport(user32.dll)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); [DllImport(user32.dll)] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); [DllImport(user32.dll)] private static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); [DllImport(user32.dll)] private static extern bool AdjustWindowRect(ref RECT lpRect, uint dwStyle, [MarshalAs(UnmanagedType.Bool)] bool bMenu); [DllImport(user32.dll)] private static extern int GetSystemMetrics(int nIndex); // 窗口样式索引 private const int GWL_STYLE -16; private const int GWL_EXSTYLE -20; // 窗口样式常量 private const uint WS_POPUP 0x80000000; private const uint WS_VISIBLE 0x10000000; private const uint WS_SYSMENU 0x00080000; private const uint WS_THICKFRAME 0x00040000; // 可调整大小的边框 private const uint WS_CAPTION 0x00C00000; // WS_BORDER | WS_DLGFRAME private const uint WS_OVERLAPPEDWINDOW WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | 0x00000000L; // 扩展窗口样式 private const uint WS_EX_APPWINDOW 0x00040000; private const uint WS_EX_TOPMOST 0x00000008; private const uint WS_EX_LAYERED 0x00080000; private const uint WS_EX_TRANSPARENT 0x00000020; // SetWindowPos 标志 private const uint SWP_NOSIZE 0x0001; private const uint SWP_NOMOVE 0x0002; private const uint SWP_NOZORDER 0x0004; private const uint SWP_FRAMECHANGED 0x0020; private const uint SWP_SHOWWINDOW 0x0040; private static readonly IntPtr HWND_NOTOPMOST new IntPtr(-2); private static readonly IntPtr HWND_TOPMOST new IntPtr(-1); // 系统度量 private const int SM_CXSCREEN 0; private const int SM_CYSCREEN 1; private const int SM_CXFULLSCREEN 16; private const int SM_CYFULLSCREEN 17; // RECT 结构体 [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } private IntPtr hwnd; private bool isBorderless false; }注意这里使用了SetWindowLongPtr和GetWindowLongPtr。在64位系统上窗口样式值是一个64位指针IntPtr。虽然旧教程常用SetWindowLong但为了64位兼容性*Ptr版本更安全。Unity在构建64位应用时会正确处理。3.2 第二步初始化与窗口句柄获取我们需要在Unity窗口创建并显示后获取其窗口句柄。Awake或Start时机可能过早窗口尚未就绪。我推荐在OnEnable中开始尝试并使用一个延迟调用或协程来确保获取成功。void Start() { // 尝试获取窗口句柄可能需要等待几帧 StartCoroutine(InitializeBorderlessWindow()); } private System.Collections.IEnumerator InitializeBorderlessWindow() { // 等待几帧确保Unity窗口已创建并激活 yield return new WaitForSeconds(0.1f); hwnd GetActiveWindow(); if (hwnd ! IntPtr.Zero) { Debug.Log($成功获取窗口句柄: {hwnd}); ApplyBorderless(); } else { Debug.LogError(无法获取窗口句柄); } }3.3 第三步核心函数——应用无边框样式并定位窗口这是最关键的一步。我们将创建一个ApplyBorderless函数它负责获取当前窗口的样式。移除标题栏和边框样式。计算屏幕的工作区即不包含任务栏的区域。将窗口设置到工作区的位置和大小。private void ApplyBorderless() { if (hwnd IntPtr.Zero) return; // 1. 获取当前窗口样式 IntPtr currentStyle GetWindowLongPtr(hwnd, GWL_STYLE); // 移除我们不想要的样式 uint newStyle (uint)currentStyle.ToInt64(); newStyle ~(WS_CAPTION | WS_THICKFRAME | WS_SYSMENU); // 确保窗口是弹出式窗口无父窗口并可见 newStyle | WS_POPUP | WS_VISIBLE; // 2. 应用新样式 SetWindowLongPtr(hwnd, GWL_STYLE, new IntPtr(newStyle)); // 3. 获取主显示器的工作区尺寸 // 注意Screen.currentResolution返回的是整个屏幕的分辨率包含任务栏 // 我们需要的是工作区。可以使用System.Windows.Forms但为了减少依赖我们用GetSystemMetrics计算。 // 一个更准确的方法是使用MONITORINFO和GetMonitorInfo这里先用简化版。 int screenWidth GetSystemMetrics(SM_CXSCREEN); // 屏幕总宽度 int screenHeight GetSystemMetrics(SM_CYSCREEN); // 屏幕总高度 // SM_CXFULLSCREEN 和 SM_CYFULLSCREEN 通常返回工作区大小主显示器 int workAreaWidth GetSystemMetrics(SM_CXFULLSCREEN); int workAreaHeight GetSystemMetrics(SM_CYFULLSCREEN); // 计算任务栏占据的边假设任务栏在底部 int taskbarHeight screenHeight - workAreaHeight; // 窗口位置应为(0, 0)大小等于工作区大小 int windowX 0; int windowY 0; // 如果任务栏在顶部这里可能需要调整 int windowWidth workAreaWidth; int windowHeight workAreaHeight; // 4. 设置窗口位置和大小并强制重绘窗口框架 SetWindowPos( hwnd, HWND_NOTOPMOST, // 不要设置为始终置顶否则可能影响任务栏 windowX, windowY, windowWidth, windowHeight, SWP_FRAMECHANGED | SWP_SHOWWINDOW ); // 5. 可选也调整一下Unity的Screen设置保持内部逻辑一致 Screen.SetResolution(windowWidth, windowHeight, FullScreenMode.Windowed); // 重要将Unity的全屏模式设置为窗口化避免内部冲突 Screen.fullScreenMode FullScreenMode.Windowed; isBorderless true; Debug.Log($已应用无边框模式。窗口尺寸: {windowWidth}x{windowHeight}, 工作区: {workAreaWidth}x{workAreaHeight}); }3.4 第四步处理屏幕分辨率变化与窗口焦点用户可能会在游戏运行时更改分辨率、切换显示器或者按WinD显示桌面。我们需要监听这些事件并重新调整窗口。void Update() { // 示例按F11切换无边框模式用于调试 if (Input.GetKeyDown(KeyCode.F11)) { ToggleBorderless(); } // 可以在这里添加对分辨率变化的检测频率不要太高 // 一个简单的方法是缓存上一次的分辨率并在每N帧检查一次 } private void ToggleBorderless() { if (!isBorderless) { ApplyBorderless(); } else { // 恢复为普通窗口带边框 // 这里需要重新添加WS_OVERLAPPEDWINDOW样式并可能重置窗口位置 // 实现略逻辑与ApplyBorderless相反 } } // 一个更健壮的方法响应Unity的OnApplicationFocus事件 void OnApplicationFocus(bool hasFocus) { if (hasFocus isBorderless) { // 应用重新获得焦点时确保窗口位置和样式正确 // 有时从最小化恢复或AltTab回来后窗口状态会异常 StartCoroutine(RefreshWindowAfterDelay(0.1f)); } } private System.Collections.IEnumerator RefreshWindowAfterDelay(float delay) { yield return new WaitForSeconds(delay); if (isBorderless) { ApplyBorderless(); // 重新应用一次 } }4. 进阶问题与精细化处理上面的基础实现已经能让窗口无边框并保留任务栏了。但在实际项目中你可能会遇到更棘手的问题。4.1 多显示器支持与任务栏位置判断我们的简化版代码假设任务栏在主显示器底部。但用户的任务栏可能在顶部、左侧、右侧或者使用了多显示器任务栏可能只在主显示器或所有显示器上显示。解决方案使用更精确的EnumDisplayMonitors和GetMonitorInfoAPI来获取每个显示器的信息特别是MONITORINFO.rcWork工作区矩形和MONITORINFO.rcMonitor整个显示器矩形。通过比较这两个矩形可以判断任务栏的位置和大小。然后将你的Unity窗口定位到目标显示器的rcWork区域。核心思路确定你的游戏窗口当前在哪一个显示器上可以通过窗口中心点坐标判断。获取该显示器的MONITORINFO。将窗口的位置和大小设置为rcWork。实操心得对于多显示器游戏如赛车游戏的后视镜投屏到副屏你需要为每个窗口实例单独处理。通常Unity单实例应用只处理主窗口。多显示器全屏覆盖需要更复杂的逻辑可能涉及创建多个“无边框”窗口并同步渲染这超出了本文范围但思路是相通的——为每个窗口句柄单独应用样式和定位。4.2 Windows 11 Snap Layout适配Windows 11引入了漂亮的窗口贴靠布局Snap Layout。当你将鼠标悬停在窗口的最大化按钮上时会出现布局选项。我们自定义的无边框窗口默认可能不支持这个功能。原因Snap Layout依赖窗口的某些标准样式和行为比如WS_THICKFRAME可调整边框和系统菜单。我们为了无边框移除了它们。折中方案一种方法是不完全移除WS_THICKFRAME。在Windows 10/11中一个带有WS_THICKFRAME但没有WS_CAPTION的窗口视觉上边框极细甚至在某些主题下不可见但仍然支持窗口拖拽边缘调整大小并且可能会触发Snap Layout。你可以尝试保留WS_THICKFRAME但将窗口边框的绘制区域设置为0这需要处理WM_NCCALCSIZE消息更为复杂。更简单的方案在应用内自己实现一套贴靠逻辑或者接受不支持原生Snap Layout的事实。对于追求极致原生体验的应用需要深入研究Windows窗口消息处理WndProc这属于高级主题。4.3 性能与输入穿透无边框窗口有时会引发意外的性能问题或输入问题。GPU缩放如果你的游戏分辨率与显示器原生分辨率不同显卡驱动可能会进行缩放。在无边框全屏窗口下确保在Unity的Player Settings中设置了正确的分辨率并考虑使用Screen.SetResolution时传入的第三个参数fullscreenMode。鼠标锁定与光标在无边框模式下如果你使用了Cursor.lockState CursorLockMode.Locked要确保在适当的时候如打开游戏内菜单解锁光标否则玩家可能无法点击任务栏。处理OnApplicationFocus事件来解锁光标是个好习惯。窗口拖拽移除了标题栏如何拖动窗口你需要自己实现在Update中检测鼠标在窗口顶部区域的按下事件然后调用ReleaseCapture和SendMessage发送WM_NCLBUTTONDOWN消息到标题栏区域HTCAPTION。网上有现成的Unity插件或代码片段实现此功能。4.4 与Unity UI的兼容性确保你的UI Canvas的缩放模式Canvas Scaler能正确适配新的窗口分辨率。通常使用Scale With Screen Size模式并参考分辨率设置为你的目标工作区分辨率如1920x1080。在ApplyBorderless后可能需要手动调用Canvas.ForceUpdateCanvases()来立即刷新UI布局。5. 常见问题排查与调试技巧在实现过程中你肯定会遇到各种奇怪的问题。这里列一个速查表问题现象可能原因排查步骤与解决方案窗口没有变化仍有边框1. 未成功获取窗口句柄。2. API调用失败。3. 调用时机太早窗口未创建。1. 在ApplyBorderless开头打印hwnd确保不是IntPtr.Zero。2. 使用Marshal.GetLastWin32Error()获取API调用错误码。3. 将初始化协程的等待时间加长或尝试在Awake、Start、OnEnable等多个生命周期事件中尝试。窗口变成一个小方块或位置错误窗口位置和大小计算错误特别是工作区计算有误。1. 打印screenWidth/Height和workAreaWidth/Height看是否正确。2. 考虑任务栏不在底部的情况。使用System.Windows.Forms.Screen.PrimaryScreen.WorkingArea需引用System.Windows.Forms获取更准确的工作区但会增加依赖。任务栏被遮挡窗口被设置为了“置顶”WS_EX_TOPMOST或者Z序管理不当。1. 检查SetWindowPos调用确保hWndInsertAfter参数是HWND_NOTOPMOST。2. 确保没有在其他地方如某些全屏插件设置了置顶属性。AltTab切换缓慢或黑屏窗口样式过于“非标”导致系统窗口切换器AltTab处理效率低。1. 尝试保留WS_EX_APPWINDOW扩展样式这有助于窗口在任务栏和AltTab列表中正确显示。2. 避免使用复杂的扩展样式如WS_EX_LAYERED分层窗口用于透明除非必要。游戏内鼠标点击“穿透”到了后面窗口可能意外设置了WS_EX_TRANSPARENT样式或者输入处理逻辑有误。1. 检查是否错误地添加了WS_EX_TRANSPARENT。2. 检查Unity的EventSystem和UI射线投射是否正常。应用失去焦点后无法恢复窗口状态在失去焦点时被系统或其他应用改变。1. 在OnApplicationFocus(true)中强制重新应用一次无边框样式和位置如示例所示。2. 监听WM_ACTIVATE等Windows消息进行更精确的控制需要设置窗口过程WndProc。调试技巧使用Debug.Log大量输出在关键步骤获取句柄、计算尺寸、调用API前后打印信息。创建调试用热键像示例中的F11键可以快速切换有无边框模式方便对比。使用SpyWindows SDK工具这是一个神器。运行你的Unity应用然后用Spy找到你的游戏窗口可以实时查看其窗口句柄、样式、扩展样式、位置大小等信息。与你代码中设置的值进行对比能快速定位问题。分步测试先实现一个简单的功能只移除边框不改变窗口大小和位置。成功了再添加定位到工作区的功能。最后处理多显示器等复杂情况。实现一个完美的PC端无边框窗口就像在Unity的便捷性与操作系统的原始力量之间走钢丝。它没有一键解决方案需要你对两个系统都有一定的理解。但一旦搞定它带来的沉浸式体验和专业的应用质感绝对是值得的。希望这篇详尽的解析能帮你扫清障碍如果遇到了上面没覆盖的怪问题不妨去社区看看很可能已经有先驱者踩过类似的坑了。
Unity PC端无边框窗口实现:保留任务栏的Windows API实战指南
发布时间:2026/7/12 13:18:49
1. 项目概述为什么PC端无边框窗口是个“技术活”如果你用Unity开发过PC平台的游戏或应用大概率遇到过这样的需求想要一个沉浸感十足、没有标题栏和边框的窗口但又不想让应用完全“霸占”屏幕用户还得能随时切出去回个消息、看看任务栏。这个看似简单的“无边框窗口保留任务栏”功能在Unity里实现起来可不像在编辑器里勾选一个“Fullscreen Mode”那么简单。它背后涉及到的是Windows或macOS/Linux原生窗口系统与Unity渲染引擎之间一场精密的“握手协议”。我接手过好几个项目从独立游戏到专业工具软件都卡在这个需求上。美术和策划想要极致的视觉沉浸而程序则要兼顾用户的系统交互习惯和应用的稳定性。Unity内置的FullScreenMode无论是Exclusive FullScreen还是Borderless Window都难以完美平衡这两点独占全屏会接管整个显示输出导致AltTab切换卡顿甚至黑屏且完全隐藏任务栏而无边框窗口模式虽然保留了任务栏但窗口位置和大小管理、多显示器适配、以及某些系统特性如Windows 11的Snap Layout的支持往往不尽如人意。所以我们需要自己动手深入到平台原生API的层面去“雕刻”出我们想要的窗口形态。这不仅仅是调用一两个函数而是理解Unity的渲染循环如何与操作系统的窗口消息泵协同工作。接下来我会拆解整个技术方案从设计思路到每一行关键代码并分享我趟过的坑和总结的实战技巧。2. 核心思路在“全屏”与“窗口”之间找到平衡点我们的目标很明确创建一个视觉上铺满整个屏幕的“窗口”但这个窗口没有标题栏、边框和系统菜单同时操作系统的任务栏以及其他系统UI元素如通知中心必须保持可见和可交互。2.1 方案选型为什么不用Unity内置选项首先我们排除掉Unity内置的简单选项Screen.fullScreen true(独占全屏)此模式下Unity应用程序直接控制整个显示器的帧缓冲区。任务栏、AltTab界面等都会被完全隐藏。切换延迟高在多显示器环境下行为复杂且容易引发驱动兼容性问题。不适合需要频繁系统交互的应用。Screen.fullScreenMode FullScreenMode.FullScreenWindow(无边框窗口全屏)这是Unity提供的“无边框全屏窗口”。它确实创建了一个无边框、铺满屏幕的窗口。但是在多数Windows版本上这个窗口会覆盖在任务栏之上导致任务栏被遮挡。用户必须使用Win键或CtrlEsc才能唤出开始菜单体验割裂。因此我们必须寻求平台原生API的帮助。对于Windows平台这也是PC端最主要的平台这意味着使用user32.dll中的函数来直接操作窗口样式和位置。2.2 技术原理窗口样式与位置的魔法Windows的每个窗口都有一系列样式Window Styles例如WS_CAPTION标题栏、WS_THICKFRAME可调整大小的边框、WS_SYSMENU系统菜单。要实现无边框核心就是移除这些样式。但同时我们还需要保留窗口的某些特性使其仍然被系统识别为一个顶级窗口并能与任务栏正常交互。这里的关键是移除视觉边框去掉WS_CAPTION,WS_THICKFRAME等。保留任务栏交互确保窗口不是“顶层”窗口WS_EX_TOPMOST在某些情况下需谨慎使用并且其Z序窗口叠放顺序正常。精准控制窗口位置和大小将窗口的位置(0, 0)和尺寸设置为与屏幕的工作区Work Area或整个屏幕Screen一致。“工作区”是指屏幕减去任务栏占用的区域这正是我们想要的效果——窗口填满任务栏之外的所有空间。整个流程可以概括为在Unity窗口创建后立即获取其原生窗口句柄HWND然后通过P/Invoke调用user32.dll的函数修改其样式和尺寸最后每帧或响应屏幕分辨率变化时确保其状态正确。3. 实战使用Windows API实现无边框任务栏窗口我们将创建一个名为BorderlessWindowController的C#脚本来完成所有工作。这个脚本应该附加在场景中一个永不销毁的GameObject上如GameManager。3.1 第一步声明所需的Windows API函数与常量首先我们需要在C#中声明将要使用的Win32 API函数和窗口样式常量。using System; using System.Runtime.InteropServices; using UnityEngine; public class BorderlessWindowController : MonoBehaviour { // 引入必要的Win32 API函数 [DllImport(user32.dll)] private static extern IntPtr GetActiveWindow(); [DllImport(user32.dll)] private static extern bool SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); [DllImport(user32.dll)] private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); [DllImport(user32.dll)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); [DllImport(user32.dll)] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); [DllImport(user32.dll)] private static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); [DllImport(user32.dll)] private static extern bool AdjustWindowRect(ref RECT lpRect, uint dwStyle, [MarshalAs(UnmanagedType.Bool)] bool bMenu); [DllImport(user32.dll)] private static extern int GetSystemMetrics(int nIndex); // 窗口样式索引 private const int GWL_STYLE -16; private const int GWL_EXSTYLE -20; // 窗口样式常量 private const uint WS_POPUP 0x80000000; private const uint WS_VISIBLE 0x10000000; private const uint WS_SYSMENU 0x00080000; private const uint WS_THICKFRAME 0x00040000; // 可调整大小的边框 private const uint WS_CAPTION 0x00C00000; // WS_BORDER | WS_DLGFRAME private const uint WS_OVERLAPPEDWINDOW WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | 0x00000000L; // 扩展窗口样式 private const uint WS_EX_APPWINDOW 0x00040000; private const uint WS_EX_TOPMOST 0x00000008; private const uint WS_EX_LAYERED 0x00080000; private const uint WS_EX_TRANSPARENT 0x00000020; // SetWindowPos 标志 private const uint SWP_NOSIZE 0x0001; private const uint SWP_NOMOVE 0x0002; private const uint SWP_NOZORDER 0x0004; private const uint SWP_FRAMECHANGED 0x0020; private const uint SWP_SHOWWINDOW 0x0040; private static readonly IntPtr HWND_NOTOPMOST new IntPtr(-2); private static readonly IntPtr HWND_TOPMOST new IntPtr(-1); // 系统度量 private const int SM_CXSCREEN 0; private const int SM_CYSCREEN 1; private const int SM_CXFULLSCREEN 16; private const int SM_CYFULLSCREEN 17; // RECT 结构体 [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } private IntPtr hwnd; private bool isBorderless false; }注意这里使用了SetWindowLongPtr和GetWindowLongPtr。在64位系统上窗口样式值是一个64位指针IntPtr。虽然旧教程常用SetWindowLong但为了64位兼容性*Ptr版本更安全。Unity在构建64位应用时会正确处理。3.2 第二步初始化与窗口句柄获取我们需要在Unity窗口创建并显示后获取其窗口句柄。Awake或Start时机可能过早窗口尚未就绪。我推荐在OnEnable中开始尝试并使用一个延迟调用或协程来确保获取成功。void Start() { // 尝试获取窗口句柄可能需要等待几帧 StartCoroutine(InitializeBorderlessWindow()); } private System.Collections.IEnumerator InitializeBorderlessWindow() { // 等待几帧确保Unity窗口已创建并激活 yield return new WaitForSeconds(0.1f); hwnd GetActiveWindow(); if (hwnd ! IntPtr.Zero) { Debug.Log($成功获取窗口句柄: {hwnd}); ApplyBorderless(); } else { Debug.LogError(无法获取窗口句柄); } }3.3 第三步核心函数——应用无边框样式并定位窗口这是最关键的一步。我们将创建一个ApplyBorderless函数它负责获取当前窗口的样式。移除标题栏和边框样式。计算屏幕的工作区即不包含任务栏的区域。将窗口设置到工作区的位置和大小。private void ApplyBorderless() { if (hwnd IntPtr.Zero) return; // 1. 获取当前窗口样式 IntPtr currentStyle GetWindowLongPtr(hwnd, GWL_STYLE); // 移除我们不想要的样式 uint newStyle (uint)currentStyle.ToInt64(); newStyle ~(WS_CAPTION | WS_THICKFRAME | WS_SYSMENU); // 确保窗口是弹出式窗口无父窗口并可见 newStyle | WS_POPUP | WS_VISIBLE; // 2. 应用新样式 SetWindowLongPtr(hwnd, GWL_STYLE, new IntPtr(newStyle)); // 3. 获取主显示器的工作区尺寸 // 注意Screen.currentResolution返回的是整个屏幕的分辨率包含任务栏 // 我们需要的是工作区。可以使用System.Windows.Forms但为了减少依赖我们用GetSystemMetrics计算。 // 一个更准确的方法是使用MONITORINFO和GetMonitorInfo这里先用简化版。 int screenWidth GetSystemMetrics(SM_CXSCREEN); // 屏幕总宽度 int screenHeight GetSystemMetrics(SM_CYSCREEN); // 屏幕总高度 // SM_CXFULLSCREEN 和 SM_CYFULLSCREEN 通常返回工作区大小主显示器 int workAreaWidth GetSystemMetrics(SM_CXFULLSCREEN); int workAreaHeight GetSystemMetrics(SM_CYFULLSCREEN); // 计算任务栏占据的边假设任务栏在底部 int taskbarHeight screenHeight - workAreaHeight; // 窗口位置应为(0, 0)大小等于工作区大小 int windowX 0; int windowY 0; // 如果任务栏在顶部这里可能需要调整 int windowWidth workAreaWidth; int windowHeight workAreaHeight; // 4. 设置窗口位置和大小并强制重绘窗口框架 SetWindowPos( hwnd, HWND_NOTOPMOST, // 不要设置为始终置顶否则可能影响任务栏 windowX, windowY, windowWidth, windowHeight, SWP_FRAMECHANGED | SWP_SHOWWINDOW ); // 5. 可选也调整一下Unity的Screen设置保持内部逻辑一致 Screen.SetResolution(windowWidth, windowHeight, FullScreenMode.Windowed); // 重要将Unity的全屏模式设置为窗口化避免内部冲突 Screen.fullScreenMode FullScreenMode.Windowed; isBorderless true; Debug.Log($已应用无边框模式。窗口尺寸: {windowWidth}x{windowHeight}, 工作区: {workAreaWidth}x{workAreaHeight}); }3.4 第四步处理屏幕分辨率变化与窗口焦点用户可能会在游戏运行时更改分辨率、切换显示器或者按WinD显示桌面。我们需要监听这些事件并重新调整窗口。void Update() { // 示例按F11切换无边框模式用于调试 if (Input.GetKeyDown(KeyCode.F11)) { ToggleBorderless(); } // 可以在这里添加对分辨率变化的检测频率不要太高 // 一个简单的方法是缓存上一次的分辨率并在每N帧检查一次 } private void ToggleBorderless() { if (!isBorderless) { ApplyBorderless(); } else { // 恢复为普通窗口带边框 // 这里需要重新添加WS_OVERLAPPEDWINDOW样式并可能重置窗口位置 // 实现略逻辑与ApplyBorderless相反 } } // 一个更健壮的方法响应Unity的OnApplicationFocus事件 void OnApplicationFocus(bool hasFocus) { if (hasFocus isBorderless) { // 应用重新获得焦点时确保窗口位置和样式正确 // 有时从最小化恢复或AltTab回来后窗口状态会异常 StartCoroutine(RefreshWindowAfterDelay(0.1f)); } } private System.Collections.IEnumerator RefreshWindowAfterDelay(float delay) { yield return new WaitForSeconds(delay); if (isBorderless) { ApplyBorderless(); // 重新应用一次 } }4. 进阶问题与精细化处理上面的基础实现已经能让窗口无边框并保留任务栏了。但在实际项目中你可能会遇到更棘手的问题。4.1 多显示器支持与任务栏位置判断我们的简化版代码假设任务栏在主显示器底部。但用户的任务栏可能在顶部、左侧、右侧或者使用了多显示器任务栏可能只在主显示器或所有显示器上显示。解决方案使用更精确的EnumDisplayMonitors和GetMonitorInfoAPI来获取每个显示器的信息特别是MONITORINFO.rcWork工作区矩形和MONITORINFO.rcMonitor整个显示器矩形。通过比较这两个矩形可以判断任务栏的位置和大小。然后将你的Unity窗口定位到目标显示器的rcWork区域。核心思路确定你的游戏窗口当前在哪一个显示器上可以通过窗口中心点坐标判断。获取该显示器的MONITORINFO。将窗口的位置和大小设置为rcWork。实操心得对于多显示器游戏如赛车游戏的后视镜投屏到副屏你需要为每个窗口实例单独处理。通常Unity单实例应用只处理主窗口。多显示器全屏覆盖需要更复杂的逻辑可能涉及创建多个“无边框”窗口并同步渲染这超出了本文范围但思路是相通的——为每个窗口句柄单独应用样式和定位。4.2 Windows 11 Snap Layout适配Windows 11引入了漂亮的窗口贴靠布局Snap Layout。当你将鼠标悬停在窗口的最大化按钮上时会出现布局选项。我们自定义的无边框窗口默认可能不支持这个功能。原因Snap Layout依赖窗口的某些标准样式和行为比如WS_THICKFRAME可调整边框和系统菜单。我们为了无边框移除了它们。折中方案一种方法是不完全移除WS_THICKFRAME。在Windows 10/11中一个带有WS_THICKFRAME但没有WS_CAPTION的窗口视觉上边框极细甚至在某些主题下不可见但仍然支持窗口拖拽边缘调整大小并且可能会触发Snap Layout。你可以尝试保留WS_THICKFRAME但将窗口边框的绘制区域设置为0这需要处理WM_NCCALCSIZE消息更为复杂。更简单的方案在应用内自己实现一套贴靠逻辑或者接受不支持原生Snap Layout的事实。对于追求极致原生体验的应用需要深入研究Windows窗口消息处理WndProc这属于高级主题。4.3 性能与输入穿透无边框窗口有时会引发意外的性能问题或输入问题。GPU缩放如果你的游戏分辨率与显示器原生分辨率不同显卡驱动可能会进行缩放。在无边框全屏窗口下确保在Unity的Player Settings中设置了正确的分辨率并考虑使用Screen.SetResolution时传入的第三个参数fullscreenMode。鼠标锁定与光标在无边框模式下如果你使用了Cursor.lockState CursorLockMode.Locked要确保在适当的时候如打开游戏内菜单解锁光标否则玩家可能无法点击任务栏。处理OnApplicationFocus事件来解锁光标是个好习惯。窗口拖拽移除了标题栏如何拖动窗口你需要自己实现在Update中检测鼠标在窗口顶部区域的按下事件然后调用ReleaseCapture和SendMessage发送WM_NCLBUTTONDOWN消息到标题栏区域HTCAPTION。网上有现成的Unity插件或代码片段实现此功能。4.4 与Unity UI的兼容性确保你的UI Canvas的缩放模式Canvas Scaler能正确适配新的窗口分辨率。通常使用Scale With Screen Size模式并参考分辨率设置为你的目标工作区分辨率如1920x1080。在ApplyBorderless后可能需要手动调用Canvas.ForceUpdateCanvases()来立即刷新UI布局。5. 常见问题排查与调试技巧在实现过程中你肯定会遇到各种奇怪的问题。这里列一个速查表问题现象可能原因排查步骤与解决方案窗口没有变化仍有边框1. 未成功获取窗口句柄。2. API调用失败。3. 调用时机太早窗口未创建。1. 在ApplyBorderless开头打印hwnd确保不是IntPtr.Zero。2. 使用Marshal.GetLastWin32Error()获取API调用错误码。3. 将初始化协程的等待时间加长或尝试在Awake、Start、OnEnable等多个生命周期事件中尝试。窗口变成一个小方块或位置错误窗口位置和大小计算错误特别是工作区计算有误。1. 打印screenWidth/Height和workAreaWidth/Height看是否正确。2. 考虑任务栏不在底部的情况。使用System.Windows.Forms.Screen.PrimaryScreen.WorkingArea需引用System.Windows.Forms获取更准确的工作区但会增加依赖。任务栏被遮挡窗口被设置为了“置顶”WS_EX_TOPMOST或者Z序管理不当。1. 检查SetWindowPos调用确保hWndInsertAfter参数是HWND_NOTOPMOST。2. 确保没有在其他地方如某些全屏插件设置了置顶属性。AltTab切换缓慢或黑屏窗口样式过于“非标”导致系统窗口切换器AltTab处理效率低。1. 尝试保留WS_EX_APPWINDOW扩展样式这有助于窗口在任务栏和AltTab列表中正确显示。2. 避免使用复杂的扩展样式如WS_EX_LAYERED分层窗口用于透明除非必要。游戏内鼠标点击“穿透”到了后面窗口可能意外设置了WS_EX_TRANSPARENT样式或者输入处理逻辑有误。1. 检查是否错误地添加了WS_EX_TRANSPARENT。2. 检查Unity的EventSystem和UI射线投射是否正常。应用失去焦点后无法恢复窗口状态在失去焦点时被系统或其他应用改变。1. 在OnApplicationFocus(true)中强制重新应用一次无边框样式和位置如示例所示。2. 监听WM_ACTIVATE等Windows消息进行更精确的控制需要设置窗口过程WndProc。调试技巧使用Debug.Log大量输出在关键步骤获取句柄、计算尺寸、调用API前后打印信息。创建调试用热键像示例中的F11键可以快速切换有无边框模式方便对比。使用SpyWindows SDK工具这是一个神器。运行你的Unity应用然后用Spy找到你的游戏窗口可以实时查看其窗口句柄、样式、扩展样式、位置大小等信息。与你代码中设置的值进行对比能快速定位问题。分步测试先实现一个简单的功能只移除边框不改变窗口大小和位置。成功了再添加定位到工作区的功能。最后处理多显示器等复杂情况。实现一个完美的PC端无边框窗口就像在Unity的便捷性与操作系统的原始力量之间走钢丝。它没有一键解决方案需要你对两个系统都有一定的理解。但一旦搞定它带来的沉浸式体验和专业的应用质感绝对是值得的。希望这篇详尽的解析能帮你扫清障碍如果遇到了上面没覆盖的怪问题不妨去社区看看很可能已经有先驱者踩过类似的坑了。