PAYATHON 2026

Windows x86_64 汇编窗口向左或向上缩放时抖动

排障赵师傅

结论

这类抖动主要与窗口的尺寸调整方式和重绘策略有关,通常不是 FillRect 的性能问题,也不是 immediate mode 本身无法平滑缩放。具体有以下几个原因:

  1. 窗口类设置了 CS_HREDRAW | CS_VREDRAW。每次宽度或高度变化,整个客户区都会失效,连续缩放时会触发大量全窗口重绘。
  2. 窗口使用 WS_POPUP,再通过 WM_NCHITTEST 模拟可调整边框,却没有保留系统用于可调整窗口的 WS_THICKFRAME。
  3. 向左或向上扩展时,窗口的左上角位置和尺寸会同时变化。DWM 需要协调窗口表面的移动和新内容的提交,这种情况下更容易出现不同步。
  4. 双缓冲可以减少复杂内容绘制时的闪烁,但无法解决窗口几何位置与 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 长时间关闭绘制。这样通常会导致拖动时内容停滞,松开鼠标后再突然跳变。

建议的修改顺序

可以按以下顺序逐项测试,以便确认每项修改的实际效果:

  1. 去掉 CS_HREDRAW | CS_VREDRAW。
  2. 加回 WS_THICKFRAME。
  3. 如果需要无边框外观,再处理 WM_NCCALCSIZE。
  4. 将单色画刷设为 wc.hbrBackground,避免重复处理背景擦除。
  5. 等界面绘制变复杂后,再加入持久化内存位图或其他后备缓冲。
  6. 程序退出时调用 DeleteObject(hbr) 释放画刷。

完成这些修改后,单色窗口通常无需 GPU 或专门的 retained-mode GUI,也能平滑调整尺寸。DWM、显示缩放、远程桌面或显卡驱动仍可能带来轻微的帧间差异,但当前这种明显的方向性抖动应该会得到改善。

备注:内容仅供参考。