缓冲更新、顶点布局与同步风险
缓冲更新、顶点布局与同步风险:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能区分
bufferData、bufferSubData、buffer copy 与 map range 的存储和同步语义 - 能计算交错与分批布局的 stride/offset,并用
sizeof/offsetof适配 C++ 对齐 - 能解释
vertexAttribPointer如何捕获当时的ARRAY_BUFFER,以及 EBO 绑定为何属于 VAO 状态 - 能设计频繁更新缓冲的 orphaning 流程,避免覆盖 GPU 仍在读取的存储
- 能分析越界更新、属性错位、状态捕获错误和隐式同步停顿
为什么不能只会每帧整块重传
前面几章你忙着对画好的画面做手脚——测试、混合、后处理。这一章往回退一步,看顶点数据怎样进入驱动管理的 GPU buffer、又怎样被解释成属性。
把 buffer 想成仓库里的一排货架:你可以一次把整架货换新,也可以只补中间一格;可以让一个顶点的属性挨着摆,也可以把同类属性各堆一段。怎么摆、何时补货,会影响上传量、缓存访问和同步等待。
这一章要解决的就是:怎样定义存储、局部更新和布局,并避免 CPU/GPU 同时争用同一块数据。不懂这些,既可能因错误 stride 读花,也可能因隐式同步出现难解释的卡顿。
缓冲:一块能任意摆字节的货架
你之前建的 VBO、EBO,本质都是一个 ↡由 OpenGL 驱动管理的连续字节存储。API 不保证它物理上始终位于独立显存;用途由绑定目标、访问方式和消费它的命令决定。vertexAttribPointer 会捕获当时的 ARRAY_BUFFER,而 ELEMENT_ARRAY_BUFFER 绑定保存在 VAO 中。(buffer)。它不关心字节代表位置、法线还是索引,实际存储位置由驱动管理,不能简单等同于“永远在显存”。目标决定当前命令操作哪个绑定点,VAO 等对象还会记录部分绑定关系。
理解这一点很关键:本章后面所有操作,都只是「对这块字节货架做不同的搬运动作」。先看怎么上货。
上货的两种方式:整架换新 vs 只补一格
把数据灌进缓冲,最常见的是 ↡一次性给缓冲分配空间并灌满数据:glBufferData(target, size, data, usage)。它会(重新)开辟 size 大小的存储、把 data 整块拷进去。如果 data 传 NULL,则只开辟空间、不填内容(之后再用 glBufferSubData 分段填)。每调一次就是「整架货推倒重来」。——它一次性给缓冲开辟空间、把整块数据灌进去,相当于「整架货全部换新」。如果只想先占好位置、内容晚点再填,可以把数据参数传 NULL(WebGL2 里传容量数字),就只分配空间不填东西。
可如果你只是想改其中一小段(比如一个顶点动了),整架推倒重来太浪费。这时用 ↡在已存在的缓冲上只更新一小段:glBufferSubData(target, offset, size, data)。它不重新分配空间,只把从字节 offset 处起、长 size 的那一段覆盖成新的 data,其余字节保持原样。前提是这块缓冲已经用 glBufferData 开辟过空间。只有一小段数据变化时用它,比每帧整块重传省得多。——它不重新分配空间,只把从字节 offset 起、长 size 的那一段覆盖掉,其余字节原封不动,相当于「只补中间那一格」。前提是这块缓冲已经用 glBufferData 开辟过空间。下面这张图把两者画在同一块缓冲上对照:
局部更新字节更少,但不保证一定更快:若上一帧 draw 仍在读取同一存储,驱动可能让 CPU 等待。对每帧整体变化的流式数据,常用 orphaning:再次 glBufferData(target, size, NULL, usage) 请求一块同尺寸新存储,再上传新内容,让 GPU 继续读旧存储。
还有一种更直接的写法叫 ↡桌面 OpenGL 将 buffer 的全部或区间映射到 CPU 地址空间。返回指针并不保证零拷贝,也不自动消除同步;访问 flags、GPU 是否仍使用该区间以及 unmap 结果共同决定正确性和性能。WebGL2 不暴露该 API。(glMapBuffer / glMapBufferRange)。CPU 可通过返回指针读写,但映射不是“免费直通显存”:若 GPU 仍在使用旧数据,普通映射可能等待;使用 invalidate/unsynchronized flags 时则由应用保证不覆盖忙碌区域。WebGL2 不提供映射 API,使用 bufferSubData。
在缓冲之间搬货:glCopyBufferSubData
有时你想把一块缓冲的字节原样复制到另一块缓冲,不必读回 CPU。↡在两个 buffer 对象之间执行服务端字节复制:从 read offset 读取 size 字节,写到 write offset。命令不需要应用把内容读回 CPU,但实际存储位置和复制引擎由驱动决定。 就干这事。通常把两块缓冲分别绑到 GL_COPY_READ_BUFFER 和 GL_COPY_WRITE_BUFFER,再给出双方偏移与长度。
同一批顶点的两种摆法:交错 vs 分批
货怎么摆,比怎么上货更影响性能。同一批顶点(每个带位置、法线、纹理坐标),有两种截然不同的摆法。
第一种是你之前一直用的 ↡把一个顶点的各个属性(位置、法线、纹理坐标)紧挨着连续存放,下一个顶点紧跟其后,循环重复:P N U | P N U | P N U …。一个顶点的数据是连续的一小块。好处是只用一个 VBO、GPU 读一个顶点时它的各属性就在一起(缓存友好),是最常用的摆法。(interleaved):一个顶点的位置、法线、纹理坐标挨着摆,下一个顶点紧跟其后,循环重复(P N U | P N U | …)。一个顶点的数据是连续的一小块,GPU 读一个顶点时各属性就在一起,缓存友好,是最常用的摆法。
第二种是 ↡把所有顶点的同类属性堆在一起:所有位置排成一整段、所有法线排成另一整段、所有纹理坐标排成又一整段(P P P P … | N N N N … | U U U U …)。每个属性各占缓冲里连续的一块。适合「某一类属性单独更新」或从分散的数组直接分段上传的场景。(batched):把所有顶点的同类属性堆在一起——所有位置排成一整段、所有法线排成另一整段、所有纹理坐标排成又一整段(P P P P … | N N N N … | U U U U …)。当你的数据本来就分散在三个数组(一个 positions、一个 normals、一个 texCoords)里时,用 glBufferSubData 分三段灌进去最省事。
两种摆法存的是同一批数据,也都喂给同一个 VAO——区别只在于,告诉 GPU「每个属性从哪个字节起、隔多远是下一个」时,glVertexAttribPointer 的 stride(步长) 和 offset(偏移) 填得不一样。下面 Demo 单步走一遍这两种摆法,再到 §6 看代码怎么填。
还有一条常被遗漏的状态规则:调用 glVertexAttribPointer 时,VAO 会记录格式、offset,以及当时绑定在 GL_ARRAY_BUFFER 的 buffer。之后改绑别的 VBO,不会自动改掉已有属性来源;要切来源必须再次调用 pointer。EBO 的 GL_ELEMENT_ARRAY_BUFFER 绑定本身也属于当前 VAO,配置 VAO 时不要随手解绑 EBO。
单步看:同一批顶点,两种摆法
猜一猜:同样是「位置 + 法线 + 纹理坐标」这一批顶点,如果改成「所有位置堆一摞、所有法线堆一摞、所有纹理坐标堆一摞」来摆,那
glVertexAttribPointer里「跨到下一个同属性」的步长,会变大还是变小?先想一想,再单步走下面三步。
下面把交错布局、分批布局、以及两者对比拆成三步。每一步都画出字节在缓冲里实际怎么排、stride 和 offset 该填什么:
① 交错布局 interleaved:一个顶点的 P N U 挨着,循环重复
一个顶点的位置 P、法线 N、纹理坐标 U 挨着摆
,下一个顶点紧跟其后。三个属性共用同一个步长
stride = 32(一个顶点结构体的总字节数),偏移分别是
0 / 12 / 24。这是最常用、缓存最友好的摆法。
走完就清楚了:所谓「布局」,落到代码里只是 glVertexAttribPointer 那两个数字的差别。回到「猜一猜」——分批布局下,每个属性的步长变小了(从整个顶点的 32 缩成自身的 12 / 12 / 8),因为同属性在自己那一段里是紧挨着的,跨一步只需跨过一个自己。
代码逐段拆解
下面四段把本章的操作全部落到代码:全量 vs 局部上传、交错布局填 attribPointer、分批布局填 attribPointer、缓冲间复制。C++/OpenGL 与 WebGL2/TS 互为镜像。
第一段:glBufferData 全量 vs glBufferSubData 局部
glBufferData 一次性开辟空间并灌满整块;之后若只想改一段,用 glBufferSubData 给定起点 offset 和长度 size 覆盖那一段,不动其余字节。
// 全量:一次性开辟空间并灌满整块数据
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(data), data, GL_STATIC_DRAW);
// 也可只分配不填:data 传 NULL,之后再用 SubData 分段填
// glBufferData(GL_ARRAY_BUFFER, sizeof(data), NULL, GL_STATIC_DRAW);
// 局部:只覆盖从字节 24 起、长 sizeof(sub) 的那一段,其余不动
glBufferSubData(GL_ARRAY_BUFFER, 24, sizeof(sub), sub);// 全量:一次性开辟空间并灌满整块数据
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
// 也可只分配不填:第二参传容量字节数,之后再用 subData 分段填
// gl.bufferData(gl.ARRAY_BUFFER, data.byteLength, gl.STATIC_DRAW);
// 局部:只覆盖从字节 24 起的那一段(长度由 sub 自身决定),其余不动
gl.bufferSubData(gl.ARRAY_BUFFER, 24, sub);两端逻辑一一对应,但 glBufferSubData 一定要先有 glBufferData 开辟过的空间,且覆盖范围别越界。配上「只在数据变化时才 subData、不每帧整块 bufferData」,就能省下大量上传开销。接着是更要命的部分——属性指针怎么填。
第二段:交错布局的 glVertexAttribPointer
交错布局下,三个属性共用步长 stride = sizeof(Vertex) = 32(跨一步正好到下一个顶点的同属性),偏移则是各属性在结构体里的起点 0 / 12 / 24。
// 不假定结构体无 padding;让编译器给出实际 stride/offset。
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
sizeof(Vertex), (void*)0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
sizeof(Vertex), (void*)offsetof(Vertex, Normal));
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE,
sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));// 同一批顶点,交错布局:stride 全填 32 字节;offset 手算 0 / 12 / 24
const STRIDE = 8 * 4; // 8 个 float × 4 字节 = 32
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, STRIDE, 0); // 位置 @0
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, STRIDE, 3 * 4); // 法线 @12
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, STRIDE, 6 * 4); // uv @24WebGL 的 Float32Array 紧密排列时 stride 是 32、offset 是 0 / 12 / 24。C++ 结构体却可能因类型对齐产生 padding,尤其启用 aligned glm 类型时;因此实际 stride 必须用 sizeof(Vertex),各字段偏移用 offsetof,不能把 WebGL 的 32 字节常量照搬过去。
第三段:分批布局的 glVertexAttribPointer
换成分批布局,每个属性自成一段、各自连续,于是步长变成自身大小(位置法线各 12、纹理坐标 8),偏移是各段的起点——位置段从 0 起,法线段从「所有位置的总字节数」起,纹理坐标段再往后。
// 数据本来就在三个数组里:positions[] / normals[] / tex[]
// 用 SubData 分三段灌进同一个 VBO(先 bufferData(NULL) 开好总空间)
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(positions), positions);
glBufferSubData(GL_ARRAY_BUFFER, sizeof(positions), sizeof(normals), normals);
glBufferSubData(GL_ARRAY_BUFFER, sizeof(positions) + sizeof(normals),
sizeof(tex), tex);
// 各属性独立:stride=自身大小;offset=各段起点
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float),
(void*)(sizeof(positions)));
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float),
(void*)(sizeof(positions) + sizeof(normals)));// 同一批顶点的分批布局:N 个顶点
const posBytes = N * 3 * 4; // 所有位置占的字节数
const norBytes = N * 3 * 4; // 所有法线占的字节数
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 3 * 4, 0); // 位置段 @0
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 3 * 4, posBytes); // 法线段 @posBytes
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 2 * 4, posBytes + norBytes); // uv 段对比第二段就一目了然:交错布局三个 stride 都是 32、offset 是固定的 0/12/24;分批布局每个 stride 缩成自身大小、offset 是各段起点(要乘上顶点总数 N)。这两段填的是同一批数据、画出来完全一样——布局变了,attribPointer 的填法就得跟着变,这正是本章最该记牢的点。
第四段:glCopyBufferSubData 缓冲间复制(附 WebGL2 映射差异)
最后是缓冲间复制。把源缓冲绑到 GL_COPY_READ_BUFFER、目标缓冲绑到 GL_COPY_WRITE_BUFFER,再给出双方偏移和长度;应用不需要把数据读回 CPU。
// 把 vbo1 的数据复制到 vbo2,应用无需读回 CPU
glBindBuffer(GL_COPY_READ_BUFFER, vbo1);
glBindBuffer(GL_COPY_WRITE_BUFFER, vbo2);
glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
0, 0, sizeof(vertexData)); // readoffset, writeoffset, size// WebGL2 有等价的 copyBufferSubData
gl.bindBuffer(gl.COPY_READ_BUFFER, vbo1);
gl.bindBuffer(gl.COPY_WRITE_BUFFER, vbo2);
gl.copyBufferSubData(
gl.COPY_READ_BUFFER,
gl.COPY_WRITE_BUFFER,
0,
0,
sizeBytes,
);glCopyBufferSubData 的五个参数是:读目标、写目标、读偏移、写偏移、复制字节数。它和 glBufferSubData 都是「只动一段」,区别是前者源是另一块缓冲、后者源是 CPU 内存。最后单独提一句内存映射的差异:
容易踩的坑
小结
- buffer 是驱动管理的字节存储,不保证物理位置;
bufferData定义存储,bufferSubData覆盖范围,copy 在 buffer 间复制 - 小范围更新仍可能因 GPU 正在读取而 stall;流式更新可用 orphaning、多缓冲或谨慎映射避免写后读冲突
- 交错/分批布局都由 stride 与 offset 描述;WebGL 紧密数组可手算,C++ 结构体用
sizeof/offsetof处理 padding vertexAttribPointer捕获当时的ARRAY_BUFFER;EBO 绑定属于 VAO,配置顺序错误会读错资源- WebGL2 没有 map buffer;桌面映射的 flags 同时定义访问权限和同步责任,不能假定零拷贝
练习
问题 1(给定结构体算 stride/offset 实现题) 一个紧密 Float32Array 顶点是 位置 vec3 + 法线 vec3 + 纹理坐标 vec2 + 切线 vec3。按交错布局写出 4 个属性的 stride/offset;再说明 C++ 结构体为何不应硬编码同一数字。
问题 2(排错题) 某人把上面那批顶点(位置 + 法线 + 纹理坐标,交错布局)的属性指针写成了下面这样,结果模型严重扭曲。请指出两处错误,并给出修正。
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 8, 0); // 位置
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 8, 3); // 法线
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 8, 6); // 纹理坐标问题 3(同步设计题) 一个粒子 VBO 每帧全部变化,连续调用同一 buffer 的 bufferSubData 偶发卡顿。解释原因,并给出一种不覆盖 GPU 忙碌存储的更新流程。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 缓冲对象
OpenGL 驱动管理的一段连续字节存储。API 不保证它始终位于独立显存;绑定目标和消费命令决定用途。
vertexAttribPointer会记录当时的ARRAY_BUFFER,EBO 绑定则保存在 VAO 中。详见本章「缓冲」一节。- glBufferData
一次性给缓冲分配空间并灌满数据:
glBufferData(target, size, data, usage)。它会(重新)开辟size大小的存储、把整块data拷进去;data传NULL(WebGL2 传容量数)则只开空间不填。相当于「整架货推倒重来」。详见本章「上货的两种方式」。- glBufferSubData
在已存在的缓冲上只更新一小段:
glBufferSubData(target, offset, size, data)。它不重新分配空间,只把从字节offset起、长size的那段覆盖成新数据,其余不动。前提是这块缓冲已用glBufferData开过空间。它能减少传输字节,但若 GPU 仍使用该区间仍可能等待。详见本章「上货的两种方式」。- 内存映射
把缓冲映射成一段你能直接用指针读写的内存:桌面 OpenGL 的
glMapBuffer/glMapBufferRange返回一个指针,你像写普通数组一样memcpy进去、写完解除映射。是否零拷贝、是否等待取决于驱动、访问 flags 与资源是否忙碌。注意:WebGL2 没有这套 API,浏览器里改缓冲一律用glBufferSubData替代。详见本章「上货的两种方式」。- glCopyBufferSubData
在两块缓冲之间直接复制一段字节:
glCopyBufferSubData(readtarget, writetarget, readoffset, writeoffset, size)。从读缓冲取一段、写到写缓冲,应用无需把内容读回 CPU。常把两块缓冲分别绑到GL_COPY_READ_BUFFER和GL_COPY_WRITE_BUFFER再复制。详见本章「在缓冲之间搬货」。- 交错布局
interleaved:把一个顶点的各属性(位置、法线、纹理坐标)紧挨着连续存放,下一个顶点紧跟其后、循环重复(
P N U | P N U | …)。一个顶点的数据是连续的一小块,GPU 读起来缓存友好,是最常用的摆法。此布局下三属性共用同一 stride。详见本章「同一批顶点的两种摆法」。- 分批布局
batched:把所有顶点的同类属性堆在一起——所有位置一整段、所有法线一整段、所有纹理坐标一整段(
P P P … | N N N … | U U U …)。每个属性各占连续一块,适合从分散的数组分段上传、或某类属性单独更新。此布局下每个属性的 stride 等于自身大小、offset 是各段起点。详见本章「同一批顶点的两种摆法」。
版本、来源与运行边界
本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。
正式概念与状态责任
- buffer sub data:在“缓冲更新、顶点布局与同步风险”中由buffer object、映射指针与仍在使用该存储的 GPU 命令负责解释其输入、受控状态和可观察结果;运行时以buffer size、更新 offset/length、map flags、fence、属性 offset 与捕获帧定位它的第一处变化。
- buffer mapping:在“缓冲更新、顶点布局与同步风险”中由buffer object、映射指针与仍在使用该存储的 GPU 命令负责解释其输入、受控状态和可观察结果;运行时以buffer size、更新 offset/length、map flags、fence、属性 offset 与捕获帧定位它的第一处变化。
- batch vertex attributes:在“缓冲更新、顶点布局与同步风险”中由buffer object、映射指针与仍在使用该存储的 GPU 命令负责解释其输入、受控状态和可观察结果;运行时以buffer size、更新 offset/length、map flags、fence、属性 offset 与捕获帧定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“分配或 orphan 存储,更新不相交区间,再按匹配 offset 配置属性”发生后,buffer object、映射指针与仍在使用该存储的 GPU 命令应怎样改变分配大小、更新区间、map flags、属性批次布局和存储代次;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
缓冲更新、顶点布局与同步风险:状态合同
比较 glBufferData/subData、映射和分批属性布局,并显式记录 CPU/GPU 同步边界
验证场景
官方教程正式概念
logl-24 · 基线帧
buffer sub data:固定 context、资源内容与输入事件,执行“分配或 orphan 存储,更新不相交区间,再按匹配 offset 配置属性”
冻结输入:buffer sub data
buffer object、映射指针与仍在使用该存储的 GPU 命令记录分配大小、更新区间、map flags、属性批次布局和存储代次
结果:得到可重复的初始 GL 状态与资源身份
观测:buffer size、更新 offset/length、map flags、fence、属性 offset 与捕获帧中的初始快照
预期:buffer object、映射指针与仍在使用该存储的 GPU 命令得到可复查结果,并持续满足“任何写区间都在分配范围内;覆盖 in-flight 数据前等待、orphan 或显式同步”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“分配或 orphan 存储,更新不相交区间,再按匹配 offset 配置属性”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“任何写区间都在分配范围内;覆盖 in-flight 数据前等待、orphan 或显式同步”。
CPU command · GL state · GPU result
缓冲更新、顶点布局与同步风险:五段轨迹
当前观测:buffer size、更新 offset/length、map flags、fence、属性 offset 与捕获帧中的初始快照
不变量:任何写区间都在分配范围内;覆盖 in-flight 数据前等待、orphan 或显式同步
实验三:单故障与同输入恢复
注入“无同步映射 GPU 正在读取的同一范围并立即覆盖,帧间出现随机撕裂”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有buffer size、更新 offset/length、map flags、fence、属性 offset 与捕获帧一起恢复才算修复。
Single fault · first divergence · replay
缓冲更新、顶点布局与同步风险:反例与恢复
故障:无同步映射 GPU 正在读取的同一范围并立即覆盖,帧间出现随机撕裂
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“无同步映射 GPU 正在读取的同一范围并立即覆盖,帧间出现随机撕裂”
任何写区间都在分配范围内;覆盖 in-flight 数据前等待、orphan 或显式同步
buffer size、更新 offset/length、map flags、fence、属性 offset 与捕获帧
最小可重放检查
unit: logl-24
owner: buffer object、映射指针与仍在使用该存储的 GPU 命令
state_or_resource: 分配大小、更新区间、map flags、属性批次布局和存储代次
command: 分配或 orphan 存储,更新不相交区间,再按匹配 offset 配置属性
pass_invariant: 任何写区间都在分配范围内;覆盖 in-flight 数据前等待、orphan 或显式同步
single_fault: 无同步映射 GPU 正在读取的同一范围并立即覆盖,帧间出现随机撕裂
required_evidence: buffer size、更新 offset/length、map flags、fence、属性 offset 与捕获帧复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。