D3D窗口化Hook实现:老游戏全屏转窗口的兼容性方案
2026/9/14 17:43:14 网站建设 项目流程

简介:一份面向游戏开发与逆向调试爱好者的 D3D 窗口化源代码,用于将原本全屏运行的 Direct3D 游戏改为窗口模式,覆盖设备创建、交换链、呈现目标、消息循环等关键切换环节。资源共42个文件,以C/C++头文件(.h)和源文件(.cpp)为主体,辅以工程配置(.dsp/.dsw/.opt)、初始化参数(.ini)、图标(.ico)及资源脚本(.rc)等,整体仅71KB,结构精简,便于定位和改造。已有190人学习下载。通过源码可梳理D3D设备钩挂、窗口尺寸调整、UI与输入适配的实现路径,还能看到针对兼容性和稳定性的处理细节,对研究Cocos2d等引擎的D3D封装或自研窗口化辅助工具均有直接参考价值,适合具备一定C++与DirectX基础的读者深入阅读。

1. 为什么老游戏卡在全屏:D3D窗口化的切入点

2010年前后的很多3D游戏,默认全屏独占模式。一旦切到桌面,屏幕黑上几秒,严重的直接崩溃。做兼容性维护时,第一反应就是让游戏改成窗口化运行。D3D窗口化源代码做的事,就是在游戏进程里拦截D3D对象创建和呈现调用,把设备初始化参数从全屏改到窗口模式。这套源码包里的dxhook.cpp、hd3d.cpp、hddraw.cpp,分别对应Direct3D和DirectDraw的钩子实现,对做老游戏兼容、直播采集、多开测试的场景尤其实用。

它解决的核心矛盾是:游戏不允许用户选择窗口模式,但底层渲染接口是标准的,只要在初始化阶段改几个参数,窗口化就生效了。后面几章会把设备创建、消息循环、渲染后端差异和验证方式一条线讲完,照着改就能复现。

2. 从设备创建到交换链:Hook链路的三个关键点

2.1 为什么选择在CreateDevice层动手

窗口化不是简单改一下窗口样式就行的。D3D游戏在启动时会调用CreateDevice,或者CreateDeviceEx,传入D3DPRESENT_PARAMETERS结构体。这个结构体里有一堆决定全屏还是窗口的字段:Windowed表示是否窗口化,BackBufferWidthBackBufferHeight是后备缓冲区尺寸,SwapEffect决定呈现方式,PresentationInterval控制垂直同步。几乎所有老游戏都会硬编码把Windowed设为FALSE,以全屏独占模式启动。

直接在函数入口改参数,比事后用PostMessage调整窗口更可靠。因为D3D设备创建后,后备缓冲区的格式、深度格式都和窗口绑定,如果等设备建好了再强行改窗口样式,画面会拉伸、闪烁,甚至直接无法渲染。所以在CreateDevice层拦截,把Present Parameters改掉,是最干净的做法。这套源代码里的hd3d.cpp就是干这个的,它导出的是一个钩子函数,在游戏调用原始CreateDevice之前插入修改逻辑,属于典型的D3D窗口化hook。

2.2 修改Present Parameters的代码骨架

常见做法是用Detours或者IAT Hook,替换D3D的创建函数。以D3D9为例,Hook点有两个:Direct3DCreate9返回的IDirect3D9对象的CreateDevice方法。在C++里,类方法本质是虚表指针,可以直接替换虚表里的地址,先保存原始CreateDevice指针,再换成自己写的代理函数。下面的代码演示了最核心的替换逻辑。

typedef HRESULT(WINAPI* CreateDeviceFunc)( IDirect3D9* pD3D, UINT Adapter, D3DDEVTYPE DeviceType, HWND hFocusWindow, DWORD BehaviorFlags, D3DPRESENT_PARAMETERS* pPresentationParameters, IDirect3DDevice9** ppReturnedDeviceInterface); static CreateDeviceFunc RealCreateDevice = nullptr; HRESULT WINAPI HookCreateDevice( IDirect3D9* pD3D, UINT Adapter, D3DDEVTYPE DeviceType, HWND hFocusWindow, DWORD BehaviorFlags, D3DPRESENT_PARAMETERS* pParams, IDirect3DDevice9** ppDevice) { D3DPRESENT_PARAMETERS oldParams = *pParams; // 备份,失败时回退 // 核心:把全屏独占改成窗口化,后缓冲尺寸交给D3D自动匹配 pParams->Windowed = TRUE; pParams->BackBufferWidth = 0; pParams->BackBufferHeight = 0; // 窗口模式下FLIP交换效果容易失败,改COPY pParams->SwapEffect = D3DSWAPEFFECT_COPY; // 关闭垂直同步,避免窗口化后帧率锁半 pParams->PresentationInterval = D3DPRESENT_INTERVAL_IMMEDIATE; HRESULT hr = RealCreateDevice(pD3D, Adapter, DeviceType, hFocusWindow, BehaviorFlags, pParams, ppDevice); if (FAILED(hr)) { // 设备创建失败时回退原始参数,最小程度干预 return RealCreateDevice(pD3D, Adapter, DeviceType, hFocusWindow, BehaviorFlags, &oldParams, ppDevice); } return hr; }

这段代码的关键是Windowed = TRUEBackBufferWidth = 0。当后缓冲宽高为0时,D3D会自动使用窗口客户区的大小去匹配后备缓冲区,这样窗口拖动后画面不会留黑边。SwapEffect改成COPY有一个副作用,旧的翻转效果D3DSWAPEFFECT_FLIP在全屏独占下才有意义,窗口模式下部分驱动会报D3DERR_INVALIDCALL,所以必须改。PresentationInterval设成IMMEDIATE是为了让窗口不锁帧率,否则某些游戏在窗口化后帧率会被锁到桌面刷新率的一半,体感就是明显的卡顿。

如果游戏用的是D3D9Ex的CreateDeviceEx,还需要额外处理D3DDDI_DRIVERESCAPE之类的扩展参数,老游戏基本不涉及。源码包里把这段逻辑放在hd3d.cpp里,实际接入时只需要把RealCreateDevice替换成从原始虚表取出的指针,Hook过程不需要碰游戏的代码段,安全性高很多。

2.3 交换链配置与窗口样式的联动

CreateDevice修改完之后,设备创建好了,但窗口本身还是游戏原来的全屏窗口。D3D9在窗口化模式下要求窗口具备WS_OVERLAPPED等样式,不能是一层不可见的全屏窗口。所以Hook里通常还要调SetWindowLongPtrA去改窗口风格,同时把窗口尺寸设置成合理的初始大小。下面的表格是这套源码里常见的参数改动:

参数或样式全屏模式窗口化模式说明
WindowedFALSETRUE主开关,决定D3D设备创建目标
BackBufferWidth如192000表示跟随窗口客户区
BackBufferHeight如10800同上
SwapEffectD3DSWAPEFFECT_FLIPD3DSWAPEFFECT_COPY避免全屏独占要求
Window styleWS_POPUPWS_OVERLAPPEDWINDOW让窗口有标题栏和边框
ShowWindow命令SW_SHOWMAXIMIZEDSW_SHOW初始状态改为普通显示

窗口样式的修改时机要在Present参数生效之后,但不能抢在游戏自己的窗口初始化代码之前,否则游戏后续可能重新设置回全屏风格。通常的做法是延迟到第一次Present调用时再改,这样既躲开了游戏自身的窗口初始化流程,也保证画面已经由D3D接管。这份源代码的MainFrm.cpp和dxwnd.cpp里就有类似处理,先挂接Present,再一次性调整窗口。

3. 窗口Proc与消息循环:窗口化后的事件适配

3.1 子类化窗口:接管WM_SIZE与WM_MOVE

游戏从全屏切到窗口后,第一个暴露的问题就是消息循环。全屏独占模式下,游戏通常只处理键盘和鼠标事件,窗口尺寸不需要关心。但窗口模式下,用户会拖动窗口边框、最小化、最大化,游戏内部的视口和后备缓冲区如果不跟着变,画面就停在一个固定大小,周围全是黑边。

解决办法是对游戏窗口做子类化。通过SetWindowLongPtr把窗口过程替换成自己写的WndProc,在挂接函数里先处理窗口消息,再把不需要的消息交给原始窗口过程。这里有个细节:子类化必须拿到正确的游戏主窗口句柄,一般从EnumWindows或者HookCreateWindowExA/W时截获,源码包里的TargetDlg.cpp处理的就是这个查找逻辑。

LONG_PTR originalWndProc = GetWindowLongPtrA(hwnd, GWLP_WNDPROC); LRESULT CALLBACK HostWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_SIZE: { int newWidth = LOWORD(lParam); int newHeight = HIWORD(lParam); // 窗口尺寸变化,通知游戏逻辑重新布局 if (newWidth > 0 && newHeight > 0) { g_windowSizeChanged = true; g_clientSize.cx = newWidth; g_clientSize.cy = newHeight; } break; } case WM_MOVE: // 记录窗口位置,多显示器场景下调整鼠标映射 GetWindowRect(hwnd, &g_windowRect); break; case WM_ACTIVATE: // 失活时释放鼠标锁定,避免光标困在窗口内 if (LOWORD(wParam) == WA_INACTIVE) { ReleaseCapture(); } break; } return CallWindowProc((WNDPROC)originalWndProc, hwnd, msg, wParam, lParam); }

这里不直接调用Reset是有原因的,WM_SIZE是窗口过程里实时触发的,如果在这里重置D3D设备,设备背后的交换链和渲染目标会被释放,而游戏正在渲染中途,绝大多数老游戏都会崩溃。所以先用一个全局标记记录尺寸变化,等游戏自己的渲染循环跑到安全点再处理。D3D9在窗口模式下其实会自动缩放后备缓冲区,WM_SIZE里真正要通知的是游戏逻辑层,让UI重算布局。如果游戏没有布局逻辑,那什么都不做也能正常显示,只是拉伸后有点糊。

3.2 处理鼠标锁定与输入焦点

全屏游戏为了让鼠标不出界,通常用SetCursorPos把鼠标钉在屏幕中心,或者直接设置裁剪区域。窗口化之后,这些逻辑会造成鼠标被限制在一个看不见的矩形里。处理这类问题,需要在窗口失去焦点时自动释放鼠标捕获,重新获得焦点时再恢复游戏原本的鼠标锁定逻辑。常见消息包括:

  • WM_CAPTURECHANGED:立即释放内部鼠标锁定标志。
  • WM_ACTIVATEwParam低16位等于WA_ACTIVE时表示窗口获得焦点,可以恢复游戏自己的输入处理;WA_INACTIVE时暂停输入。
  • WM_SETFOCUSWM_KILLFOCUS:同步更新游戏的焦点状态,很多老游戏用焦点判断是否暂停渲染。

如果游戏使用了DirectInput的GetDeviceState去读鼠标相对位移,窗口化后鼠标一直在窗口内移动,相对位移数据反而正常,不需要额外处理。真正的问题是使用绝对坐标的老游戏,这时候需要在WndProc里做一次坐标换算,把屏幕坐标转换成游戏逻辑坐标。映射公式:逻辑X = (屏幕X - 窗口客户区左上角X) * 游戏分辨率宽度 / 客户区宽度,Y方向同理。这个映射可以放在WM_MOUSEMOVE分支里,修改lParam之后再往下传,避免游戏拿到的坐标还是旧的全屏坐标。

3.3 与原始消息循环的共存策略

窗口化之后,最怕出现两个消息循环同时跑。游戏自己有一个PeekMessage循环,子类化的窗口过程也在收消息,两者如果都在处理渲染,画面就会撕裂或卡顿。成熟的做法是不要介入游戏的主循环,只修改窗口过程和D3D函数的返回结果,让游戏自己继续跑原来的PeekMessage

不过这里有个坑:很多游戏的主循环是while (true) { if (PeekMessage) DispatchMessage; game_render(); }。如果子类化WndProc里调用了Sleep或者弹窗调试,游戏的主循环会停住,外部看起来像界面卡死。我一般会把调试日志写在单独线程里,而不是直接在WndProc里输出。另一个共存策略是定期从窗口过程里提取状态,比如窗口是否被最大化,用原子变量标记,游戏渲染循环里再读取这个标记决定是否调整D3D后缓冲,这样两边互不阻塞。

4. 兼容性边界:老D3D、Cocos2d与渲染后端的差异

4.1 D3D8/D3D9接口差异

这套源码里的hd3d.cpp同时处理D3D8和D3D9,但两者的Hook方式差很多。D3D8的CreateDeviceIDirect3D8::CreateDevice,虚表结构跟D3D9基本一致,可以直接替换。不过D3D8的D3DPRESENT_PARAMETERS里没有PresentationInterval,垂直同步由FullScreen_PresentationIntervalWindowed_PresentationInterval两个字段控制,窗口化时要改的是后者。

字段/方法D3D8D3D9
设备创建方法IDirect3D8::CreateDeviceIDirect3D9::CreateDevice / CreateDeviceEx
窗口化开关WindowedWindowed
后缓冲尺寸BackBufferWidth/Height同左
垂直同步FullScreen_PresentationInterval / Windowed_PresentationIntervalPresentationInterval
扩展呈现参数D3DPRESENT_PARAMETERS 已包含

需要特别留意,D3D8的SwapEffect如果原来是FLIP,窗口化后改成D3DSWAPEFFECT_COPY,个别显卡驱动会忽略这个设置,仍然使用覆盖层翻转,表现就是窗口化后帧率反而更低。这种情况只能保留FLIP,并用DwmFlush来同步帧。老游戏还有一个常见问题:设备创建时用D3DCREATE_PUREDEVICE,这种设备不保留软件顶点处理状态,窗口化后如果UI渲染依赖SetTransform,容易出现画面闪烁,需要在Hook里把BehaviorFlags里的PUREDEVICE去掉。

4.2 Cocos2d游戏引擎下的窗口化注意点

Cocos2d在大多数平台上是OpenGL渲染,但在一些Windows移植版里,也有用D3D作为后端的选择。如果你的目标是这类游戏,不能只依赖拦截D3D的CreateDevice,因为Cocos2d-x在Windows上主要走CCEGLView_win32.cpp里的OpenGL路径,跟D3D窗口化代码没有交集。所以对Cocos2d游戏,更实用的入口是Win32Window的创建过程,直接修改初始化的窗口风格,让窗口正常显示。

Cocos2d这类游戏引擎通常有自己的分辨率适配策略,比如setDesignResolutionSize。窗口化后,设计分辨率和实际窗口客户区不一致,渲染出来会按缩放模式拉伸。因为缩放逻辑在引擎内部,外部Hook很难精确介入,比较省事的办法是保持窗口客户区等于设计分辨率,禁止用户调整窗口大小。也就是给窗口加上WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX,去掉WS_THICKFRAMEWS_MAXIMIZEBOX,这样窗口比例固定,UI不会乱。

4.3 渲染目标和视口重置

窗口化之后,另一个隐蔽的问题是视口。游戏初始化时会把视口设置成全屏宽高,比如1600x900。窗口化后客户区变成800x600,D3D的视口却没有更新,结果只渲染了画面左上角800x600的区域,其余部分全是黑色。所以必须在第一次Present之后,主动调用SetViewport把视口重设为窗口客户区大小。

D3DVIEWPORT9 vp; vp.X = 0; vp.Y = 0; vp.Width = clientWidth; // 通过GetClientRect获得 vp.Height = clientHeight; vp.MinZ = 0.0f; vp.MaxZ = 1.0f; device->SetViewport(&vp);

这里MinZMaxZ保持默认的0和1,绝大部分游戏都会在后面自行重置,我们只需要保证设备Create之后没有立即用一个错误尺寸的视口去渲染。渲染目标同理,窗口化后不需要再设置ColorBufferDepthBuffer为全屏尺寸,直接用默认的RenderTarget和DepthStencilSurface就行。一旦这两处都改对,画面撕裂和黑边的问题能消除一半。

5. 验证与调试:如何确认窗口化真正生效

5.1 用日志确认拦截点

窗口化没有效果时,第一步是确认Hook到底有没有命中。在HookCreateDevice的入口写一行日志,输出原始参数中的Windowed值和被修改之后的值,然后在RealCreateDevice返回后再输出一次HRESULT。如果日志里连入口都没有,说明Hook没有挂上,要么是游戏启动时先加载了D3D,要么是源码包里的注入时机不对。dxwnd.ini或dxhook.cpp里通常有调试开关,打开后输出的日志会记录每一次调用的虚拟地址。

5.2 用RenderDoc验证帧呈现

RenderDoc支持D3D9,但需要以application capture方式启动游戏。启动后手动切换窗口模式,抓一帧,查看D3D9 Device的创建参数。在RenderDoc的API Inspector里能看到Present调用的源和目标,如果PresentationInterval一直是IMMEDIATE,说明D3D层已经生效。再检查Texture View,如果纹理尺寸是游戏原始分辨率,而窗口较小,说明视口部分没有完全适配,回看4.3的SetViewport是否正确调用。窗口模式下不推荐抓取过多帧,D3D9窗口化后的Present会触发相对缓慢的重绘,抓帧速度会明显下降。

5.3 快速验证窗口样式和常见失败对照

下面的PowerShell命令可以快速读取游戏进程的主窗口句柄和标题,配合Spy++确认窗口样式是否正确应用。

powershell -Command "Get-Process game | ForEach-Object { $_.MainWindowTitle; $_.MainWindowHandle }"

获取到句柄后,用GetWindowLong检查样式值,如果WS_POPUP还在,说明窗口样式没有被替换,回到2.3的时机问题。常见失败现象直接对照这张表:

现象可能原因检查点
游戏还是全屏启动CreateDevice没被拦截日志是否有HookCreateDevice入口
窗口有边框但画面黑屏SwapEffect或视口错误RenderDoc查看第一次Present前视口
窗口化后鼠标点击错位鼠标坐标未换算WM_MOUSEMOVE分支是否修改lParam
拖窗口边缘画面不跟着变未处理WM_SIZE确认窗口过程是否被子类化
帧率比全屏低一半垂直同步冲突确认PresentationInterval为IMMEDIATE
最小化后再恢复黑屏设备Reset被跳过在WM_SIZE里是否触发了延迟Reset标记

最后再补充一个验证点:窗口化后的分区暗盘或数值显示类游戏,如果窗口尺寸被锁定,可以用ChangeDisplaySettingsEx配合窗口位置微调。不过对大多数D3D老游戏,做到上面这些就已经能稳定窗口化了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询