ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CEF OSR高性能离屏渲染:D3D11共享纹理合成与vsync调优

CEF OSR高性能离屏渲染:D3D11共享纹理合成与vsync调优

简介:面向希望在桌面应用中嵌入Web内容并实现高性能离屏渲染的C++开发者,cef-mixer演示了基于CEF的OSR完整实现路径。项目围绕Chromium Embedded Framework展开,采用C++承载后端逻辑、HTML/CSS/JS构建界面,并通过DirectX 11调用GPU加速,配合VSync控制帧输出,重点解决屏幕外合成、纹理共享与渲染效率问题。压缩包共30个文件、约138KB,以cpp与h源码为核心,辅以CMake构建脚本、针对CEF版本与共享纹理的patch、Windows批处理生成脚本及md说明文档,代码按composition、web_layer、image_layer、d3d11等模块组织,并附带HUD界面与图标资源,便于理解OSR渲染管线及叠加层混合方式。已有1879人学习,适合具备一定C++与图形基础、想研究CEF嵌入、D3D11合成或GPU加速离屏方案的开发者参考,可从中获取可运行的工程骨架、构建配置与关键补丁思路,也可作为排查离屏渲染卡顿、纹理更新和帧同步问题的对照材料。

1. 从一张 1080p 网页画面说起:cef-mixer 到底解决哪类 OSR 卡顿

把带视频、Canvas 和复杂 CSS 动画的 HTML 页面嵌进 C++ 桌面端,最直接的做法是开一个可见的 CEF 窗口;一旦要把画面合成进自己的 D3D11 场景、做多图层混合或无窗口截图,就得走 OSR(off-screen rendering)。cef-mixer 是一份围绕 CEF 高性能离屏渲染的演示工程,重点不在“把网页画到内存位图再贴图”,而在 GPU 加速路径:Chromium 侧出共享纹理,C++ 侧用 DirectX11/D3D11 打开、合成、按 vsync 呈现。

它适合已经能把 CEF 跑起来、却被 CPU 回读和帧率抖动卡住的人;也适合做录屏、虚拟屏、桌面端混合 UI 的团队先跑通链路,再谈参数。反直觉的点是:OSR 的性能上限往往不在 CEF,而在你怎样处理共享纹理和帧节奏。下面按“链路原理、最小跑通、D3D11 合成、排错、验证”拆开写,能抄的代码和参数都放进去。

2. CEF OSR 的渲染链路:从 OnPaint 位图到 D3D11 共享纹理

2.1 两条交付路径:CPU 位图与 GPU 共享纹理

CEF 的 OSR 不是只有一种取帧方式。最老的是 CPU 路径:实现CefRenderHandler::OnPaint,CEF 把一整帧 BGRA 位图拷到调用方给的 buffer 里,应用再上传纹理或直接画到窗口。这条路兼容性好,调试直观,缺点是每帧一次 CPU 侧内存拷贝。1080p 的 BGRA 帧约 8 MB,60 fps 就是每秒约 480 MB 的写入量,还没算你把这张位图再上传到 GPU 的带宽和同步开销;页面里再有视频或高频动画,掉帧会很直接。

GPU 路径则是在较新的 CEF 分支里实现OnAcceleratedPaint,CEF 不再给像素指针,而是给一个共享纹理句柄。C++ 侧用 D3D11 设备OpenSharedResource打开它,直接在自己的合成管线里采样。画面没有经过 CPU 回读,省掉大块内存拷贝,帧节奏也更容易和Present对齐。代价是同步、格式、设备匹配这些细节会变成“黑匣子”之外的新问题,处理不好就是黑屏或撕裂。

选型时不要一上来就追求 GPU 路径。先跑 CPU 路径确认浏览器创建、页面加载、尺寸和输入都正常,再把OnPaint当作对照基线;等OnAcceleratedPaint能稳定出帧,再切掉 CPU 回读。这个顺序能帮你把“CEF 没出帧”和“D3D11 合成写错了”分开,少走很多弯路。

2.2 为什么是 D3D11 共享纹理,而不是软件拷贝或 D3D9

关键词里出现directx11、d3d11、gpu-acceleration,对应的就是 CEF 在 D3D11 后端下的共享纹理路径。选 D3D11 的理由不是“新”,而是链路短:Chromium 的 GPU 进程在 D3D11 后端渲染,OSR 共享纹理本身是 D3D11 texture,应用侧用ID3D11Device::OpenSharedResource打开后可以直接进 shader,不需要经过 D3D9 中转或 ANGLE 再转一次。每多一层转译,就多一次拷贝或一次格式转换,帧时间会变得不可预测。

另一个理由是交换链和 vsync 控制权在自己手里。D3D11 的IDXGISwapChain::Present(1, 0)可以把呈现阻塞到垂直同步,配合外部 BeginFrame 能把网页帧率和合成帧率拉开;如果走软件拷贝,往往只能在 UI 线程里定时贴图,帧节奏被消息循环拖着走,页面一复杂就出现“有时 60、有时 30”的玄学抖动。

需要提前确认的是设备匹配。打开共享纹理的 D3D11 设备必须和 CEF 出纹理的适配器一致,多显卡机器上要显式枚举适配器,别让两边各自选了不同 GPU。另一个检查点是纹理格式,CEF 共享纹理常见为 BGRA8,合成时交换链和采样器要按这个格式对齐;格式对不上时不一定报错,可能只是颜色发暗或通道错位,这类问题靠日志很难看出来,只能靠首帧截图对比。

检查项CPU 位图路径D3D11 共享纹理路径
取帧回调OnPaintOnAcceleratedPaint
每帧 CPU 拷贝有,整帧 BGRA无,直接共享句柄
同步重点UI 线程与渲染线程共享纹理读写、设备匹配
适合场景调试、低频截图高帧率合成、视频、复杂动画
首帧验证看 buffer 是否有像素看句柄能否打开、首帧是否 Present

2.3 一帧的完整时序:回调、打开资源、合成、Present

把一帧拆开看,CEF 侧完成渲染后,浏览器进程的 UI 线程会触发OnAcceleratedPaint,参数里带共享纹理句柄和脏矩形列表。应用侧不要在这个回调里直接做重活,更不要直接Present,否则 CEF 的 UI 线程会被你的合成管线拖住。常见做法是把句柄和脏矩形记到一个待合成队列,立刻返回;合成线程拿到队列后打开共享资源、采样到自己的后备缓冲,再按 vsync 呈现。

如果启用了external_begin_frame_enabled,CEF 不会自己按固定频率推帧,而是等应用调用CefBrowserHost::SendExternalBeginFrame。这一步很关键:它把“网页画多快”和“屏幕刷多快”解耦。你可以按显示器刷新节奏发 BeginFrame,也可以在页面不可见时降频,避免后台页面白烧 GPU。代价是配对写错就会永久不出帧,现象是浏览器已创建、页面已加载、日志也正常,但画面就是黑的。

初始化时把三个开关一次配对好,比后面逐项排查省事:

开关位置作用常见误用
windowless_rendering_enabledCefSettings打开 OSR 总开关只设窗口信息,忘了设全局
shared_texture_enabledCefWindowInfo请求 GPU 共享纹理同时期待OnPaint有像素
external_begin_frame_enabledCefWindowInfo由应用控制 BeginFrame打开后不调用SendExternalBeginFrame

提示:第一次接 GPU 路径时,保留 CPU 路径的OnPaint日志,但不要同时消费两条路径的帧。两边都开容易让你误判“画面是谁画的”。

3. 把最小 CEF OSR 跑起来:窗口、消息循环与离屏缓冲的三段配置

3.1 工程骨架:CEF 二进制目录、CMake 链接和运行时文件

先把 CEF 二进制包解压到工程内的固定目录,比如third_party/cef。目录里通常有头文件、导入库、运行时 DLL 和资源目录,不同分发结构的名字不完全一样,按实际解压结果调整 CMake 变量即可。不要把运行时 DLL 和资源目录漏掉,CEF 初始化时找不到icudtl.dat、resources.pak这类文件会直接退出,而且退出位置很深,日志不一定明显。

下面这段 CMake 只做三件事:引头文件、链libcef.lib和 D3D11 相关库、构建后把运行时文件拷到可执行文件旁边。CEF_ROOT用缓存变量,方便换目录;POST_BUILD拷贝是给本地调试用的,正式打包可以换成安装规则。

cmake_minimum_required(VERSION 3.20) project(cef_mixer_demo LANGUAGES CXX) set(CEF_ROOT "${CMAKE_SOURCE_DIR}/third_party/cef" CACHE PATH "CEF binary root") # 头文件目录按实际解压结构微调,有的包多一层 include include_directories("${CEF_ROOT}") add_executable(cef_mixer_demo src/main.cpp src/osr_client.cpp ) target_compile_definitions(cef_mixer_demo PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX ) target_link_libraries(cef_mixer_demo PRIVATE "${CEF_ROOT}/libcef.lib" d3d11 dxgi dxguid ) # 本地调试:把 CEF 运行时和资源拷到输出目录 add_custom_command(TARGET cef_mixer_demo POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CEF_ROOT}/Release" "$<TARGET_FILE_DIR:cef_mixer_demo>" COMMAND ${CMAKE_COMMAND} -E copy_directory "${CEF_ROOT}/Resources" "$<TARGET_FILE_DIR:cef_mixer_demo>" )

CEF_ROOT指向解压根目录,不要指到include里面;libcef.lib是导入库,运行时还需要对应的 DLL。d3d11、dxgi、dxguid是后面打开共享纹理和创建交换链要用的。POST_BUILD里两个copy_directory按你手头的目录名调整,有的包把 DLL 放在Release,有的放在bin,不要照抄路径不看目录。

3.2 初始化与创建无窗口浏览器:CefApp、CefClient、RenderHandler

最小可跑版本先不接 D3D11,只实现CefRenderHandler的GetViewRect和OnPaint,确认浏览器能创建、页面能加载、首帧能回调。GetViewRect返回的是逻辑像素尺寸,高 DPI 场景下要和设备像素比分开算,后面输入坐标错位多半从这里开始。OnPaint里只做标记和日志,不贴图,避免把“有没有帧”和“画得对不对”混在一起。

#include "include/cef_app.h" #include "include/cef_browser.h" #include "include/cef_client.h" class OsrClient : public CefClient, public CefRenderHandler { public: CefRefPtr<CefRenderHandler> GetRenderHandler() override { return this; } void GetViewRect(CefRefPtr<CefBrowser> browser, CefRect& rect) override { // 逻辑像素;高 DPI 下不要在这里直接乘缩放因子 rect = CefRect(0, 0, 1280, 720); } void OnPaint(CefRefPtr<CefBrowser> browser, PaintElementType type, const RectList& dirtyRects, const void* buffer, int width, int height) override { // CPU 路径只做首帧对照,确认页面确实渲染了 if (type == PET_VIEW && buffer && width > 0 && height > 0) { first_frame_ = true; } } bool first_frame_ = false; IMPLEMENT_REFCOUNTING(OsrClient); };

GetViewRect的宽高是页面布局使用的逻辑尺寸,不是最终纹理尺寸;OnPaint的type要区分PET_VIEW和PET_POPUP,下拉框、弹层走 popup,如果只处理 view,交互时会出现“页面主体正常、下拉菜单不显示”的怪现象。first_frame_这种标记只用于调试,正式代码里换成原子变量或帧计数器,避免多线程读写踩坑。

初始化顺序按“全局设置、窗口信息、浏览器设置”走。下面这段把 OSR、共享纹理、外部 BeginFrame 一次打开,先让 GPU 路径有出帧条件;如果首帧黑屏,再把shared_texture_enabled关掉,用OnPaint确认页面本身没问题。

CefMainArgs args(GetModuleHandle(nullptr)); CefSettings settings; settings.no_sandbox = true; // 演示工程先排除沙箱权限干扰 settings.windowless_rendering_enabled = true; CefInitialize(args, settings, nullptr, nullptr); CefWindowInfo window_info; window_info.SetAsWindowless(nullptr); window_info.shared_texture_enabled = true; // 请求 GPU 共享纹理 window_info.external_begin_frame_enabled = true; // 应用控制推帧节奏 CefBrowserSettings browser_settings; browser_settings.windowless_frame_rate = 60; // 上限,不是硬保证 CefRefPtr<OsrClient> client = new OsrClient(); CefBrowserHost::CreateBrowser(window_info, client, "about:blank", browser_settings, nullptr, nullptr);

no_sandbox只适合本地演示,正式环境按权限模型决定;windowless_frame_rate是上限,不是“保证 60 帧”,页面复杂或合成线程被阻塞时仍会掉。external_begin_frame_enabled打开后必须有人调SendExternalBeginFrame,否则 CEF 会等外部信号,表现就是一直不出帧。这个配对关系是后面排错章的第一条。

3.3 消息循环与首帧验证:CPU 路径当对照,GPU 路径再接管

CEF 的消息循环可以独立跑,也可以嵌到原生消息循环里。演示工程常用CefDoMessageLoopWork定时泵,或者单独开线程CefRunMessageLoop。如果主线程还要处理窗口消息、输入和合成,建议把 CEF 消息循环放到独立线程,合成线程只读帧队列,减少 UI 线程和渲染线程互相等锁。

// 主线程消息循环里定时泵 CEF,间隔不要太大,否则输入和帧回调会迟滞 while (running) { MSG msg; while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } CefDoMessageLoopWork(); // 合成线程在这里消费 pending frame,并按 vsync Present RenderThreadTick(); }

CefDoMessageLoopWork的调用频率会影响 CEF 内部任务调度,间隔太大时页面脚本和输入事件都会变慢;但也不要在一个循环里连续空转,按显示器刷新节奏或略高一点的频率泵即可。首帧验证按三步走:先看OnPaint是否触发,再看OnAcceleratedPaint是否触发,最后看共享纹理能否OpenSharedResource成功。三步任何一步断了,都能把问题锁在更小的范围内。

4. D3D11 合成与 vsync:共享纹理、帧同步和缩放采样的参数怎么定

4.1 打开共享纹理:句柄、格式和适配器必须对齐

OnAcceleratedPaint给的是共享句柄,不是ID3D11Texture2D*。应用侧要拿自己的ID3D11Device去OpenSharedResource,所以设备必须在 CEF 出纹理的同一适配器上。多显卡机器上可以先枚举IDXGIAdapter,把 CEF 的 GPU 进程和合成设备固定到同一块卡,避免“打开成功但采样全黑”这种不容易定位的问题。

void OnAcceleratedPaint(CefRefPtr<CefBrowser> browser, PaintElementType type, const RectList& dirtyRects, const CefAcceleratedPaintInfo& info) override { if (type != PET_VIEW || !info.shared_texture_handle) return; Microsoft::WRL::ComPtr<ID3D11Texture2D> tex; HRESULT hr = device_->OpenSharedResource( info.shared_texture_handle, IID_PPV_ARGS(&tex)); if (FAILED(hr)) { open_failed_count_++; return; } // 有 keyed mutex 就加锁;没有就标记为需要 CopyResource 双缓冲 Microsoft::WRL::ComPtr<IDXGIKeyedMutex> mutex; tex.As(&mutex); if (mutex) { mutex->AcquireSync(0, 16); // 16ms 超时,避免合成线程被拖死 } { std::lock_guard<std::mutex> lock(frame_mutex_); pending_tex_ = tex; pending_dirty_ = dirtyRects; } if (mutex) mutex->ReleaseSync(0); }

shared_texture_handle为空时不要继续打开,先确认shared_texture_enabled是否真的生效;OpenSharedResource失败时记录hr,常见是设备不匹配或句柄类型不对。AcquireSync的超时不要设无限大,合成线程被卡住时整个窗口都会僵;拿不到 keyed mutex 时,退化方案是在回调里立刻CopyResource到自己的双缓冲纹理,再让合成线程读本地纹理,代价是多一次 GPU 拷贝,但比读写竞争安全。

格式方面,CEF 共享纹理常见为 BGRA8,创建交换链和着色器资源视图时按DXGI_FORMAT_B8G8R8A8_UNORM对齐。如果交换链用了 sRGB 格式,采样后要做颜色空间转换,否则画面会发灰或过暗。脏矩形列表不要忽略,能局部更新就局部更新;全量重绘在 4K 下会把带宽吃满,帧时间会突然抬高。

4.2 合成管线:全屏四边形、采样和颜色空间

合成本身不复杂:一个全屏四边形,顶点着色器直接输出裁剪空间坐标,像素着色器采样共享纹理。真正影响观感的是采样方式和缩放。网页纹理尺寸和窗口客户区尺寸不一致时,线性采样比点采样平滑,但快速滚动时会有轻微模糊;整数倍缩放可以先用点采样,再在 UI 层做后处理。多图层混合时,共享纹理只代表网页层,桌面 UI 的其他层按深度或 alpha 顺序叠。

Texture2D<float4> webTex : register(t0); SamplerState linearSampler : register(s0); struct VSOut { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; VSOut VSMain(uint id : SV_VertexID) { VSOut o; // 用 SV_VertexID 生成全屏三角形,省顶点缓冲 float2 uv = float2((id << 1) & 2, id & 2); o.uv = uv; o.pos = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1); return o; } float4 PSMain(VSOut i) : SV_TARGET { // 共享纹理是 BGRA8,采样后按通道顺序输出 float4 c = webTex.Sample(linearSampler, i.uv); return float4(c.b, c.g, c.r, c.a); }

全屏三角形比两个三角形少一条对角线接缝,SV_VertexID生成 UV 的方式适合演示工程。像素着色器里做 BGRA 到 RGBA 的通道重排,如果交换链格式和纹理格式已经一致,这一步可以省掉,但通道顺序错位时画面会偏色,首帧截图对比很容易发现。采样器状态按缩放比例选:缩小用线性,放大用线性或各向异性,快速滚动出现锯齿再考虑锐化后处理。

4.3 vsync 与外部 BeginFrame:Present 参数和掉帧处理

Present(1, 0)会把呈现阻塞到垂直同步,适合大多数桌面窗口;Present(0, 0)不等待,撕裂风险高,只在测量或特殊场景用。开了外部 BeginFrame 后,推荐在合成线程按显示器刷新节奏发SendExternalBeginFrame,再Present,让网页帧和屏幕刷新对齐。不要在OnAcceleratedPaint回调里递归发 BeginFrame,否则回调链会越套越深,帧率看似很高,实际延迟在涨。

掉帧处理要区分“网页没画完”和“合成没赶上”。前者表现为OnAcceleratedPaint间隔变大,后者表现为回调正常但Present间隔抖动。前者可以降windowless_frame_rate或减少页面动画;后者先看合成线程有没有等锁、有没有全量CopyResource、有没有在 UI 线程做纹理上传。连续掉帧时不要用“补帧”硬追,队列越积延迟越大,保留最新一帧、丢掉旧帧反而更稳。

5. OSR 翻车现场排查:黑屏、撕裂、内存涨和输入错位的 5 个坑

5.1 黑屏但页面已加载:external_begin_frame_enabled 没配对

现象:浏览器创建成功,OnLoadEnd也触发了,日志里能看到页面标题,但窗口一直黑;CPU 路径下OnPaint可能也不回调,或者只回调一次。

原因:external_begin_frame_enabled打开后,CEF 等应用发SendExternalBeginFrame才推下一帧。只设开关不调用,CEF 就停在那里。另一个常见原因是GetViewRect返回了 0 宽高,CEF 认为没有可绘制区域。

解决:在合成循环里按刷新节奏调用browser->GetHost()->SendExternalBeginFrame(),并确认GetViewRect返回的宽高大于 0。先用 CPU 路径确认首帧,再切 GPU 路径;如果 GPU 路径黑、CPU 路径正常,问题就在共享纹理打开或合成,不在 CEF 初始化。

5.2 画面撕裂或 30/60 跳变:Present 参数和帧率上限打架

现象:快速滚动时画面中间有一条横向撕裂,或者帧率在 30 和 60 之间反复跳;日志里OnAcceleratedPaint间隔看起来正常。

原因:Present(0, 0)不等待垂直同步,合成线程跑得比屏幕快就会撕裂;windowless_frame_rate设了 60,但合成线程偶尔等锁超过 16 ms,下一帧就被推到下一个刷新周期,形成 30/60 跳变。

解决:桌面窗口优先Present(1, 0);合成线程里避免长时间持锁,共享纹理读取和Present分开;帧率上限按页面实际需要设,不需要 60 的页面降到 30 反而更稳。撕裂和跳变要分开看,前者是呈现参数,后者是帧节奏。

5.3 句柄和显存持续涨:共享纹理没释放、脏矩形全量刷新

现象:跑十几分钟后进程句柄数上涨,显存占用缓慢增加,切换页面后也不回落;严重时OpenSharedResource开始失败。

原因:每帧OpenSharedResource出来的ID3D11Texture2D没有及时释放,或者pending_tex_队列只进不出;另一个原因是脏矩形被忽略,每帧都按全尺寸重绘和上传,带宽和显存峰值都被抬高。

解决:共享纹理用ComPtr管理,合成完立刻释放;帧队列只保留最新一帧,旧帧主动丢。脏矩形能局部更新就局部更新,不能局部更新时至少复用同一张中间纹理,不要每帧新建。显存问题用任务管理器的 GPU 占用和自建计数器一起看,单看句柄数容易误判。

5.4 鼠标点击偏移:逻辑像素、设备像素比和 GetViewRect 混用

现象:页面能显示,但点击位置整体偏移,缩放比例越高偏移越大;或者鼠标在右下角,点击事件落到左上角。

原因:GetViewRect返回逻辑像素,CEF 的输入事件坐标也按逻辑像素算;如果应用把窗口客户区的物理像素直接传给 CEF,高 DPI 下就会按比例偏移。另一个坑是OnPaint的 buffer 尺寸和GetViewRect不一致,合成时又做了一次缩放,视觉位置和命中区域对不上。

解决:输入事件先按设备像素比换算成逻辑坐标,再调SendMouseClickEvent;GetViewRect和输入坐标用同一套逻辑尺寸,合成阶段再做纹理缩放。高 DPI 切换时重新计算device_scale_factor,不要缓存旧值。

5.5 GPU 进程崩溃后永久黑屏:设备丢失和重建没做

现象:页面跑一段时间后突然黑屏,CEF 日志显示 GPU 进程异常,重启页面也不恢复;有时OpenSharedResource一直返回失败。

原因:D3D11 设备丢失后没有重建,交换链和共享纹理都失效;或者 CEF 的 GPU 进程崩溃后,应用还拿着旧句柄继续合成。另一个原因是多显卡切换时设备适配器变了,旧设备打开新句柄失败。

解决:监听Present和OpenSharedResource的失败返回,连续失败时销毁交换链和 D3D11 设备,重新枚举适配器、重建设备,再让 CEF 重新出帧。重建期间保留 CPU 路径兜底,至少让窗口不黑。设备丢失不是玄学,日志里通常有DXGI_ERROR_DEVICE_REMOVED之类的返回码,把hr打出来比猜有用。

6. 帧率与延迟验证:用时间戳查询做一套可复现的测量法

调完参数不要只看“感觉流畅”。我一般会在三个位置打时间戳:OnAcceleratedPaint回调入口、合成线程开始采样、Present返回。用QueryPerformanceCounter记录,跑 60 秒,统计 P50 和 P95 间隔;再用 D3D11 的ID3D11Query做 GPU 时间戳,确认瓶颈在网页渲染还是合成。只看平均帧率会掩盖卡顿,P95 才能暴露偶发长帧。

指标采集位置观察阈值异常时先看
回调间隔 P95OnAcceleratedPaint入口明显高于 16.7 ms页面动画、windowless_frame_rate
合成开始到 Present合成线程持续超过一帧等锁、全量拷贝、采样器
共享纹理打开失败数OpenSharedResource大于 0 且持续增长设备匹配、句柄释放
帧队列深度待合成队列长期大于 2合成线程是否被阻塞
// 简化的帧间隔记录,正式代码用环形缓冲避免锁竞争 LARGE_INTEGER freq, now; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&now); double ms = (now.QuadPart - last_.QuadPart) * 1000.0 / freq.QuadPart; last_ = now; frame_intervals_.push_back(ms); // 每 60 秒排序取 P95,别在主循环里做排序 std::sort(frame_intervals_.begin(), frame_intervals_.end()); double p95 = frame_intervals_[frame_intervals_.size() * 95 / 100];

QueryPerformanceCounter只适合看 CPU 侧间隔,GPU 时间要用ID3D11Query的D3D11_QUERY_TIMESTAMP_DISJOINT配对采集;两者不要混在一起算。测的时候固定页面内容,先测静态页,再测视频页,最后测复杂动画页,分别记录,才能知道是链路问题还是页面本身太重。从那以后我每次接 OSR 都先跑一遍 60 秒的帧时间记录,再谈优化,不然很容易被“看起来不卡”骗过去。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进