Windows x86_64 汇编窗口向左或向上缩放时抖动
结论
这类抖动主要与窗口的尺寸调整方式和重绘策略有关,通常不是 FillRect 的性能问题,也不是 immediate mode 本身无法平滑缩放。具体有以下几个原因:
- 窗口类设置了
CS_HREDRAW | CS_VREDRAW。每次宽度或高度变化,整个客户区都会失效,连续缩放时会触发大量全窗口重绘。 - 窗口使用
WS_POPUP,再通过WM_NCHITTEST模拟可调整边框,却没有保留系统用于可调整窗口的WS_THICKFRAME。 - 向左或向上扩展时,窗口的左上角位置和尺寸会同时变化。DWM 需要协调窗口表面的移动和新内容的提交,这种情况下更容易出现不同步。
- 双缓冲可以减少复杂内容绘制时的闪烁,但无法解决窗口几何位置与 DWM 合成不同步的问题。
这个窗口只有单色背景,应先处理窗口样式和无效区,再考虑是否需要完整的 retained-mode 渲染器。
为什么左边和上边更明显
拖动右边或底边时,窗口左上角保持不变,只有宽度或高度发生变化。例如拖动右边缘:
x = 不变
width = 变化
拖动左边缘时,右边缘通常保持固定,Windows 需要同时修改窗口位置和宽度:
x = 变化
width = right - x
拖动顶部边缘也是一样,y 和 height 会同时变化。
窗口几何更新、客户区失效、WM_PAINT 处理和 DWM 提交画面并非一次完成。某一帧可能已经采用新的窗口位置,显示的却仍是上一次提交的客户区表面。向右或向下缩放不会移动客户区原点,所以这种时间差不太显眼。
填充几十万像素所需的 CPU 时间并不能直接说明问题。即使 FillRect 执行很快,后面仍有消息调度、GDI 批处理、窗口表面更新和桌面合成等环节。显示器刷新率也无法保证一次 Win32 绘制调用会在下一次垂直同步前原子完成。
先移除全窗口重绘标志
当前代码注册了:
mov dword [wc.style], CS_OWNDC + CS_HREDRAW + CS_VREDRAW
CS_HREDRAW 和 CS_VREDRAW 会在客户区宽度或高度变化时让整个窗口失效。连续调整尺寸时,这会带来不必要的全窗口重绘。
先改为:
mov dword [wc.style], CS_OWNDC
如果暂时只使用普通 GDI,也可以去掉 CS_OWNDC:
mov dword [wc.style], 0
CS_OWNDC 常用于需要长期绑定同一个 DC 的 OpenGL 程序,Vulkan 本身不依赖窗口 DC。
移除 CS_HREDRAW | CS_VREDRAW 后,Windows 可以保留客户区中仍然有效的内容,只重绘新暴露的区域。对于单色背景,这部分区域通常只是窗口边缘的一条窄带。
保留系统的可调整窗口语义
建议重新加入 WS_THICKFRAME:
mov r9d, WS_POPUP + WS_VISIBLE + WS_THICKFRAME
WS_THICKFRAME 除了控制边框,还会告诉窗口管理器,这是一个可调整尺寸的顶层窗口。系统处理尺寸调整、最大化约束、贴靠以及部分 DWM 行为时,都会参考这个窗口样式。
如果需要无标题栏、无可见边框的外观,可以保留 WS_THICKFRAME,再通过 WM_NCCALCSIZE 将非客户区改为客户区。
最小处理逻辑如下:
WM_NCCALCSIZE = 0x0083
; dispatch
cmp edx, WM_NCCALCSIZE
je .wmnccalcsize
.wmnccalcsize:
; wParam != 0 时,返回 0 可让客户区覆盖窗口区域
; 具体外观仍会受 Windows 版本、主题和 DWM 设置影响
xor eax, eax
jmp .epilog
不同 Windows 版本可能仍会保留阴影或一条由 DWM 管理的边线。如果当前目标只是消除缩放抖动,可以先确认 WS_THICKFRAME 是否有效,再单独处理非客户区外观。
让系统处理单色背景
对于单色窗口,无需在每次 WM_PAINT 中手动处理背景擦除。可以直接将画刷注册为窗口类背景画刷:
mov ecx, 0xED9564
call [CreateSolidBrush]
mov [hbr], rax
mov [wc.hbrBackground], rax
随后删除自定义的 WM_ERASEBKGND 分支,或者将该消息交给 DefWindowProcA。WM_PAINT 只需验证更新区域:
.wmpaint:
mov rcx, [rsp+64]
lea rdx, [ps]
call [BeginPaint]
mov rcx, [rsp+64]
lea rdx, [ps]
call [EndPaint]
xor eax, eax
jmp .epilog
需要擦除背景时,BeginPaint 会触发相应处理,并由类背景画刷完成填充。这样能减少自定义擦除和绘制之间的时序差异。
也可以保留现有的 FillRect,但要满足以下条件:
WM_ERASEBKGND返回非零后,后续WM_PAINT必须覆盖整个无效区。- 背景擦除和后续绘制不要使用不同颜色。
- 只绘制
ps.rcPaint,不要每次都重画整个客户区。
当前 PAINTSTRUCT.rcPaint 的 x64 偏移 12 是正确的。
复杂界面再使用双缓冲
如果窗口以后需要绘制文本、控件或其他多步内容,可以先在内存 DC 中完成一帧,再通过一次 BitBlt 提交:
HDC paintDC = BeginPaint(hwnd, &ps);
HDC memoryDC = CreateCompatibleDC(paintDC);
HBITMAP bitmap = CreateCompatibleBitmap(paintDC, width, height);
HGDIOBJ oldBitmap = SelectObject(memoryDC, bitmap);
FillRect(memoryDC, &clientRect, backgroundBrush);
/* Draw text, controls and other content here. */
BitBlt(paintDC, 0, 0, width, height, memoryDC, 0, 0, SRCCOPY);
SelectObject(memoryDC, oldBitmap);
DeleteObject(bitmap);
DeleteDC(memoryDC);
EndPaint(hwnd, &ps);
实际程序不宜在每次 WM_PAINT 中重新创建整张位图。可以在窗口尺寸变化时重建后备位图,其余时间继续复用。
双缓冲可以避免用户看到背景已经擦除、内容尚未画完的中间状态。它无法让窗口的位置、尺寸和 DWM 表面更新合并成一个原子操作,因此不适合作为左侧和顶部几何抖动的首要修复方案。
不建议采用的补救方式
使用 WS_EX_COMPOSITED、每次尺寸变化后调用 UpdateWindow,或者频繁调用 RedrawWindow(..., RDW_UPDATENOW),都可能增加同步绘制开销和输入延迟。这些方法有时能暂时掩盖症状,却会加重尺寸调整期间的工作负担。
交互式调整期间也不宜通过 WM_SETREDRAW 长时间关闭绘制。这样通常会导致拖动时内容停滞,松开鼠标后再突然跳变。
建议的修改顺序
可以按以下顺序逐项测试,以便确认每项修改的实际效果:
- 去掉
CS_HREDRAW | CS_VREDRAW。 - 加回
WS_THICKFRAME。 - 如果需要无边框外观,再处理
WM_NCCALCSIZE。 - 将单色画刷设为
wc.hbrBackground,避免重复处理背景擦除。 - 等界面绘制变复杂后,再加入持久化内存位图或其他后备缓冲。
- 程序退出时调用
DeleteObject(hbr)释放画刷。
完成这些修改后,单色窗口通常无需 GPU 或专门的 retained-mode GUI,也能平滑调整尺寸。DWM、显示缩放、远程桌面或显卡驱动仍可能带来轻微的帧间差异,但当前这种明显的方向性抖动应该会得到改善。
备注:内容仅供参考。
