从“重影”看 GPU 渲染:为什么截图会抓到重影?
本文最后更新于36 天前,其中的信息可能已经过时,如有错误请发送邮件到big_fw@foxmail.com

写这篇博文起因是因为我做笔记截图的时候,因为截图和鼠标滚轮命令同时进行,所以捕捉到了屏幕上一帧的重影,所以让我有点好奇,原因和原理到底是什么?

像素搬运

要深入了解这个问题,我们必须明确一个物理事实:显存(VRAM)本身并不具备计算能力,它本质上只是一个负责存储电荷数据的“大仓库”。

当网页发生滚动或产生透明度变化时,所有像素的逻辑运算都必须离开显存,被批量搬运到 GPU 核心内部的算术逻辑单元(ALU)中进行处理。在这个微观的加工场里,GPU 根据复杂的混合算法算好每一个像素的新颜色,然后再马不停蹄地将这些成品数据通过内部总线搬运回显存中的后缓冲区

由于 GPU 内部可供暂存的寄存器空间极其有限,这种“提取-加工-回填”的搬运过程必须像流水线一样时刻不停地高频进行,以追求极致的吞吐量。这就导致了后缓冲区在物理层面上并不是一个静止的成品,而是一个处于实时更新状态的、新旧交替的动态施工现场。

对的,显存中的缓冲区是分为前缓冲区和后缓冲区的

前缓冲区与后缓冲区

在物理层面上,显存中的缓冲区被严格划分为前缓冲区(Front Buffer)和后缓冲区(Back Buffer)两个功能区。

前缓冲区存储的是已经完全渲染完毕、正由显示器控制器实时扫描并输出到屏幕上的“成品界面”,也就是用户当前肉眼所见的画面。

与此同时,后缓冲区则充当了一个“秘密施工现场”,负责承载 GPU 提前计算并实时写入的下一帧显示信息。这种双缓冲架构的设计初衷,是为了让像素的生成过程与画面的显示过程在空间上实现解耦,确保用户在观看前台成品的稳定输出时,后台的后缓冲区可以不受干扰地进行高频的数据填充与逻辑搬运。

这里可能会产生一个核心疑问:既然渲染逻辑是后缓冲区的画面最终覆盖前缓冲区,那么理论上旧画面应该被瞬间抹除,为什么还会产生重影?

这本质上是由渲染算法的逻辑决定的。

渲染算法

以我遇到问题的Notion 为例,为了追求界面的精致感

(比如你放大notion的字体,可以看到它其实不是全黑的,边缘还会有一层灰色过渡)这是我截取放大的notion的文字:

所以notion的渲染系统在处理图层更新时并不会采用简单的“A 直接覆盖 B”的暴力算法。

相反,它执行的是

C = A *alpha + B *(1 - alpha)

的混合逻辑。

在这种算法下,新像素(A)与旧像素(B)会根据透明度系数“alpha”进行加权融合。

这就导致了旧画面在显存中并不是被瞬间“切断”,而是在计算过程中经历一个从有到无、逐渐消解的数学演变。

但其实如果系统仅仅使用最原始的覆盖逻辑,是可以避免这种中间态的产生,但代价是画面会失去所有的平滑过渡与视觉美感。

物理写回

在 GPU 的底层微观执行中,每一组像素的更新都遵循严格的 Read-Modify-Write(读-改-写) 逻辑序列。

由于 GPU 的运算单元(ALU)无法直接在显存地址上进行复杂的数学融合,

它必须先执行 Read 操作,将显存后缓冲区中的旧像素数据抓取到寄存器中;

随后在 Modify 阶段,根据透明度公式完成新旧像素的混合计算;

最后通过 Write 操作,将算好的成品数据覆盖回原有的显存地址。

这一连串动作在计算机架构中是一个具有物理耗时的流水线过程。

截图软件的抓取行为本质上是一次未经同步的异步采样,它在没有任何锁机制约束的情况下,强行闯入了这段正在剧烈变动的物理内存。如果截图动作刚好撞上了这个 RMW 循环的中间态——即旧像素数据已被读出、但新像素尚未完全写回地址的真空期——这张截图就会捕捉到那段本该在微秒间消失的“数据残骸”,将其定格为肉眼无法察觉的逻辑重影。

这里可能会产生进一步的疑问:为什么 GPU 不在内部将整张画面完整渲染后,再统一发送给后缓冲区?

这涉及到计算机体系结构中一个核心的物理限制。虽然现代 GPU 的算术逻辑单元(ALU)拥有恐怖的并行算力,但其核心内部真正能够用于高速暂存数据的寄存器区域却极其微小,通常仅以 KB 为单位,根本无法容纳动辄数百万像素的完整帧数据。

为了解决这一矛盾,图形架构被迫采用了吞吐量优先(Throughput-oriented)的策略。

这意味着 GPU 必须像永不停歇的流水线一样,将像素数据分批次、高频度地从寄存器“搬运”回显存,以这种极高的流动性来压榨宝贵的显存带宽。

在这种架构下,后缓冲区不再是一个“画好了再放进去”的静态成品仓库,而是一个物理地址段上时刻处于实时更新状态的动态施工现场。

这种为了性能而妥协的非稳态,正是重影现象在底层留下的技术缝隙。

那么疑问又来了,为什么截图软件放着完美的前缓冲区不截图,而是跑到后缓冲区抓数据呢?

前缓冲区的物理隔离与同步锁

原因之一在于前缓冲区存储的仅仅是全屏合成图

你可以将其理解为一张已经摊平、无法拆分的“成品油画”,虽然肉眼能看到不同的软件界面,但在像素层面它们已经融为一体。为了实现“自动感知”特定软件的功能,截图软件会直接询问系统的桌面窗口管理器(DWM)。在系统后台,每一个运行的软件窗口都有一个唯一的身份 ID,称为 HWND(窗口句柄)

当你移动截图准星时,软件会不断调用如 WindowFromPoint 类的系统 API,实时确认鼠标当前坐标所属的 HWND,从而精准锁定目标窗口的边界。

然而,即便锁定了坐标,在前缓冲区进行截取依然是不可行的。

因为如果在前缓冲区的这幅不变的“大油画”里强行按坐标抠出一个矩形,由于前屏显示的是最终的物理叠加结果,一旦你的 Notion 窗口被旁边的微信窗口挡住了一个角,你抠出来的截图中就会尴尬地带上一块微信的界面。

这显然违背了用户“截取特定窗口”的初衷。为了获得一张干净、完整且不被遮挡的界面,截图软件必须绕过这张全屏合影,利用 HWND 找到该软件在系统后台拥有的那个独立位图缓存(Surface/Texture)

但这虽然解决了遮挡问题,但这种方式却会设计到另外一个原因,就是执行这个步骤必须打开同步锁——

而这就是原因之二,系统其实给前缓冲区上了一个同步锁

前缓冲区是显存中一个极特殊的区域,它同时被 GPU(写入者)和显示器控制器(读取者)两个物理硬件“盯着”。写入者以极高频率刷入新像素,而读取者则按固定的刷新率(如 60Hz)从左上到右下逐行扫描。

如果没有同步锁,当显示器扫描到一半时,GPU 若突然完成新帧覆盖,屏幕就会出现上半部旧帧、下半部新帧的断层,这便是臭名昭著的画面撕裂(Tearing)

为了解决这一并发控制问题,系统引入了以 V-Sync(垂直同步) 为代表的同步锁机制。当显示器开始扫描前缓冲区时,内存会被锁定,此时即便 GPU 在后缓冲区(Back Buffer)画好了下一帧,也绝不允许执行“缓冲区交换”操作。只有等到扫描完成、进入所谓的“垂直空白期(V-Blank)”时,锁才会释放,新帧才能推向前台。

这便产生了一个性能悖论:截图软件如果要抓取“眼见为实”的画面,理应读取前缓冲区。

但截取几 MB 数据的耗时远比扫描一行像素长得多。

如果截图软件强行在前缓冲区加锁,GPU 的写入和显示器的刷新都会被瞬间阻塞,导致系统掉帧甚至鼠标卡顿。

为了确保 Windows 界面的绝对流畅,操作系统内核通常严禁普通权限软件锁定并读取前缓冲区。

于是,截图软件只能退而求其次,去读取那个虽然可能因为 RMW 循环产生重影、但完全不加锁且支持异步访问的后缓冲区

而后缓冲区又是实时变化的,很容易就会发生捕捉到rmw的中间过程

所以其实根本原因就是画面是独立的对象在更新,不同图层在显存里的“施工进度”不一,导致了像素在空间和时间上的非一致性(Inconsistent)。

至此!本文结束!其实我也相当有收获!

文末附加内容

评论

  1. 博主
    Windows Chrome
    1 月前
    2026-3-20 18:25:16

    额我注意力真是太容易发散了!我本来在做项目就因为这个重影写了快两个小时关于这个的blog!

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇