
简介cef-mixer 是一份基于 Chromium Embedded Framework (CEF) 的离屏渲染OSR演示项目CEF 作为 Chromium 的嵌入式版本为 C 应用提供网页渲染、网络处理和 JavaScript 执行能力项目面向需要在桌面端集成 Web 技术的开发者也适合关注 DirectX 11 / GPU 加速渲染的技术人员。它重点展示了如何在屏幕外完成网页渲染再通过 D3D11 纹理共享接入 GPU 管线并配合 VSync 垂直同步保证画面平滑这一方案适合游戏开发、3D渲染或视频处理等需要与 GPU 深度交互的场景。压缩包共 30 个文件、138KB核心为 6 个 cpp 与 5 个 h 源文件用于实现渲染层、合成器与 D3D11 封装另有 CMake 脚本和 bat 批处理负责工程生成patch 补丁用于修复已知 CEF 问题README、License 等文档帮助快速上手overlay.png 与 hud.html 则提供覆盖层与界面示例。该项目已有 1877 人学习通过阅读源码可以掌握 OSR 模式与外部纹理共享的衔接方法理解 GPU 加速与垂直同步在浏览器渲染中的配合机制并借鉴其分层合成设计来优化自己的离屏渲染或混合开发项目。1. CEF 高性能离屏渲染OSR演示HTML 内容如何走进 D3D11 管线如果你要给游戏、桌面工具或者自研引擎加一套网页 UI最怕的就是浏览器窗口和渲染窗口各过各的网页浮在上面还带着边框和焦点透明背景做不到点击事件还会把焦点抢走。cef-mixer 走的是另一条路用 CEFChromium Embedded Framework开启 off-screen rendering也就是离屏渲染OSR让 Chromium 在后台把 HTML/CSS/JS 画成一帧一帧的像素再通过 DirectX 11D3D11把像素上传成 GPU 纹理由自己的渲染管线做最终合成最后跟着 VSync 节奏 Present 到屏幕上。这套工程的价值在于它演示了一条“网页内容完整穿过 GPU 管线”的链路离屏、纹理上传、垂直同步、输入路由全都有最短实现。适合做过嵌入式浏览器但没摸过 D3D 端、或者想给 HUD 和地图浮层做无缝集成的人照着拆一遍就能落地成自己的 Demo。2. 核心链路拆解Chromium 离屏渲染到 D3D11 VSync 合成2.1 数据流离屏渲染的像素从哪里进又从哪里出整套工程的核心是一条单向数据流我拆的时候先在纸上画了一遍。CEF 初始化后开启 OSRChromium 的渲染子进程正常做 HTML 排版、CSS 动画、Canvas 和 GPU 合成合成结果不往屏幕上输出而是通过共享内存把像素帧传到浏览器进程触发应用层注册的回调。回调里拿到的是 BGRA 格式的原始像素这时候 D3D11 渲染器把它拷进一块 CPU 可写的动态纹理再经过一次全屏三角形采样把网页纹理绘制到后缓冲。最后调用 Present 并传入 VSync 参数等待垂直同步信号完成屏幕刷新。这条链路里最关键的转折点是 OnPaint 回调。很多第一次接触 OSR 的人会误以为 CEF 会直接把纹理句柄给你实际不是。CEF 对外给出的是一块普通内存 buffer你拿到手之后想走 GPU 合成就得自己把它从系统内存搬到显存。cef-mixer 演示的正是这一步动态纹理上传加上一次全屏三角形绘制。这个方案的好处是绕开了依赖窗口句柄的浏览器嵌入方式把网页当成一个纯纹理源后续想做透明混合、3D 贴图、多页面聚合都变得顺理成章。2.2 初始化阶段让 CEF 跑在 Windowless 模式CEF 默认会创建一个原生窗口来承载渲染结果要进入 OSR 模式必须在 CefSettings 里显式打开 windowless_rendering_enabled。下面这段是工程里初始化阶段的典型写法// 初始化 CEF进入离屏渲染模式 CefSettings settings; settings.windowless_rendering_enabled true; // 关键开关开启 OSR settings.no_sandbox true; // 演示环境直接去掉沙箱限制 settings.multi_threaded_message_loop true; // 消息循环由主进程自行驱动 // GPU 合成加速参数OSR 场景下必须保持开启 CefString(settings.arguments).FromString(enable-gpu-compositing);这里最容易踩的坑是 no_sandbox 和 GPU 加速的关系。生产环境通常不随便关沙箱但这个演示工程为了排除权限策略的干扰直接把沙箱去掉让 Chromium 的子进程能拿到 GPU 设备。而 enable-gpu-compositing 要单独确认绝对不能跟 disable-gpu 同时出现否则页面合成会退回软件渲染OSR 的帧率和画质直接腰斩。初始化完成后创建浏览器时需要传入一个 CefWindowInfo并把 window_info.SetAsWindowless 设为 true。这个对象要和 RenderHandler 实例绑定RenderHandler 负责接收页面尺寸、刷新请求和鼠标键盘事件。到这里 CEF 侧就算准备就绪接下来所有工作都围绕“如何处理回调里的像素数据”展开。2.3 核心代码OnPaint 回调把页面像素接进应用层CEF 的 OSR 模式要求在 CefRenderHandler 子类里实现 OnPaint。每当页面发生重绘CEF 就把一帧完整或局部更新的像素数据通过这个回调交给你。工程里的实现很直接把 buffer 转发给 D3D11 渲染器不在回调里做任何耗时操作。// osr_render_handler.cpp class OSRRenderHandler : public CefRenderHandler { public: void OnPaint(CefRefPtrCefBrowser browser, PaintElementType type, const RectList dirty_rects, const void* buffer, int width, int height) override { // buffer 是 BGRA 格式的一整帧像素 // 演示工程直接整帧上传不做脏矩形合并 if (d3d_renderer_ d3d_renderer_-IsReady()) { d3d_renderer_-UploadFrame( static_castconst uint8_t*(buffer), width, height); } } };逻辑说明参数里的 width 和 height 是页面逻辑分辨率buffer 是像素首地址。type 区分背景层和弹窗层想实现透明页面的话可以分别处理但第一版先整帧上传更省事。dirty_rects 是脏矩形区域列表CEF 不想让你每次都重传整帧但演示工程为了代码简洁选择忽略它。后面改造时性能瓶颈往往就在这里——内容大部分静止的页面整帧上传会白费大量带宽。2.4 核心代码D3D11 动态纹理上传与全屏三角形合成像素数据到达后D3D11 渲染器的任务是把内存 buffer 搬进纹理。工程里用的是 D3D11_USAGE_DYNAMIC 动态纹理这种纹理允许 CPU 频繁写入每次写入前用 Map 获取指针。注意纹理格式必须和 CEF 输出对齐CEF 输出的是 BGRA所以要选 DXGI_FORMAT_B8G8R8A8_UNORM选错格式就会看到红蓝互换的屏面。// d3d11_renderer.cpp bool D3D11Renderer::CreateWebTexture(int width, int height) { D3D11_TEXTURE2D_DESC desc {}; desc.Width width; desc.Height height; desc.MipLevels 1; desc.ArraySize 1; desc.Format DXGI_FORMAT_B8G8R8A8_UNORM; // 和 CEF 的 BGRA 输出对齐 desc.SampleDesc.Count 1; desc.Usage D3D11_USAGE_DYNAMIC; // CPU 写、GPU 读 desc.BindFlags D3D11_BIND_SHADER_RESOURCE; desc.CPUAccessFlags D3D11_CPU_ACCESS_WRITE; return SUCCEEDED(device_-CreateTexture2D(desc, nullptr, web_texture_)); } void D3D11Renderer::UploadFrame(const uint8_t* buffer, int width, int height) { D3D11_MAPPED_SUBRESOURCE mapped {}; if (FAILED(context_-Map(web_texture_, 0, D3D11_MAP_WRITE_DISCARD, 0, mapped))) { return; } // 演示工程假设行宽正好等于 4 * width严谨工程要按 RowPitch 逐行拷 memcpy(mapped.pData, buffer, width * height * 4); context_-Unmap(web_texture_, 0); }参数说明里有几个容易被忽略的点。D3D11_MAP_WRITE_DISCARD 是告诉驱动“这块纹理的内容我打算全部重写”驱动可以绕开旧数据的同步等待性能更高。memcpy 行拷贝有个隐患GPU 纹理的行字节数可能比 width * 4 大叫做 RowPitch。演示工程直接用连续内存拷一遍换到不同显卡或分辨率后可能踩到行错位的花屏问题留作改造点。上传完后进入绘制阶段。渲染器绑定一个全屏三角形的顶点布局Shader 里直接采样网页纹理最后调用 Draw(3, 0)。这个三角形覆盖整个 NDC 空间比绘制全屏四边形少一个顶点是现代图形 API 里处理全屏纹理的常见做法。// shaders.hlsl Texture2D WebTexture : register(t0); SamplerState WebSampler : register(s0); float4 PS(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET { return WebTexture.Sample(WebSampler, uv); }绘制命令由渲染器统一发出最终通过 SwapChain 的 Present 控制输出节奏。工程里用 Present(1, 0)第一个参数表示等待垂直同步信号避免画面撕裂。想对比性能的话可以分别用 Present(0, 0) 和 Present(1, 0) 跑同一个 CSS 动画页面观察帧率和屏幕撕裂的差别。2.5 输入路由鼠标键盘事件如何送进离屏页面OSR 模式下页面收不到系统输入必须手动把窗口消息翻译成 CEF 事件再通过 CefBrowserHost 发送给浏览器实例。工程里实现了一个简单的消息转发层从 Windows 消息里提取坐标和按键状态。// input_router.cpp void OSRRenderHandler::OnMouseEvent(UINT msg, WPARAM wParam, LPARAM lParam, CefRefPtrCefBrowserHost host) { CefMouseEvent event; event.x GET_X_LPARAM(lParam); // 注意这里是物理像素 event.y GET_Y_LPARAM(lParam); switch (msg) { case WM_LBUTTONDOWN: host-SendMouseClickEvent(event, CEF_MOUSE_BUTTON_LEFT, false, 1); break; case WM_LBUTTONUP: host-SendMouseClickEvent(event, CEF_MOUSE_BUTTON_LEFT, true, 1); break; case WM_MOUSEMOVE: host-SendMouseMoveEvent(event, false); break; default: break; } }参数说明event.x 和 event.y 是相对于 CEF 窗口的坐标CEF 内部按逻辑像素处理。如果你的主窗口启用了 DPI 缩放这里接收到的鼠标坐标必须除以缩放比例后再传进去否则点击位置会整体偏移。SendMouseClickEvent 里第三个参数是 mouseUp 标记第四个参数是点击次数双击需要写成 2。键盘事件原理相同需要把 WM_KEYDOWN 和 WM_KEYUP 翻译成 CefKeyEvent再处理焦点问题工程里只做了基础的鼠标路由键盘后续我会在改造章节里提到。3. 跑起来并验证CEF 工程构建、资源准备与 GPU 加速确认3.1 工程目录结构拿到手先认清模块边界拆这套工程时我建议先把源码目录分成四个区域这样后面调试定位会快很多。应用入口负责 CEF 初始化和主循环OSR 层专门做 RenderHandler 和输入转发D3D11 层做纹理上传和合成第三方的 CEF 二进制和页面资源单独放。cef-mixer/ ├─ CMakeLists.txt ├─ third_party/ │ └─ cef/ # CEF 发布包需手动放置 ├─ resources/ # icudtl.dat、.pak 等运行时资源 ├─ src/ │ ├─ main.cpp # 程序入口初始化 CEF 和 D3D11 │ ├─ osr/ │ │ ├─ osr_render_handler.h │ │ ├─ osr_render_handler.cpp │ │ └─ input_router.cpp │ └─ d3d11/ │ ├─ d3d11_renderer.h │ ├─ d3d11_renderer.cpp │ └─ shaders.hlsl └─ assets/ └─ ui_demo.html # 演示页面画几个 CSS 动画CEF 的发布包通常不会直接放进 Git 仓库体积太大。你拿到源码后要先去 CEF 官方发布渠道下载一个和你编译器匹配的二进制包解压后放到 third_party/cef。resources 目录里的 icudtl.dat 和 .pak 文件是 Chromium 的国际化和资源数据这些文件必须和可执行文件待在同一个运行目录漏掉一个就会在启动阶段黑屏闪退。3.2 构建步骤CMake 配置和关键的变量开关CEF 把工程构建配置封装在 cmake 目录的辅助脚本里你需要显式地把 CEF 根目录传给 CMake。典型做法是设置 CEF_ROOT 变量然后引入 cef_variables.cmake。# CMakeLists.txt set(CEF_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/third_party/cef) include(${CEF_ROOT}/cmake/cef_variables.cmake) cef_variables(cef) add_executable(cef_mixer WIN32 src/main.cpp src/osr/osr_render_handler.cpp src/osr/input_router.cpp src/d3d11/d3d11_renderer.cpp ) target_include_directories(cef_mixer PRIVATE src ${CEF_ROOT}) target_link_libraries(cef_mixer PRIVATE ${cef_library} cef_dll_wrapper)构建命令我一般直接走 CMake 命令行不打开 IDE 也能完成mkdir build cd build cmake -DCEF_ROOT../third_party/cef .. cmake --build . --config Release构建完成后需要手动把 resources 里的文件拷贝到 exe 旁边还要注意 CEF 的子进程可执行文件也会被 link 到运行目录CEF 主进程依赖这个文件来拉起渲染进程。如果启动时报错“Gpu process exited unexpectedly”或者直接进程退出第一反应就是检查运行目录里的资源文件是否完整。3.3 验证 GPU 加速是否生效任务管理器不够还需要帧时间统计判断 OSR 是否真的在用 GPU 加速分两步。第一步打开任务管理器找到进程列表里带 Chromium 字样的 GPU 进程。这个进程存在说明 GPU 合成通道已经起来但要注意 GPU 进程存在不代表你的 D3D11 合成管线在工作它只说明 Chromium 内部启用了 GPU 合成。第二步要验证 D3D11 端的合成效率。工程里建议在 UpdateFrame 入口做两个耗时统计OnPaint 回调到 UploadFrame 完成的耗时以及 Present 调用前后耗时。做法是使用 QueryPerformanceCounter 在回调入口打点把数据累加后显示在窗口标题栏。跑一个包含 CSS 动画和 Canvas 绘制的页面如果 UploadFrame 平均耗时小于 2 毫秒Present 保持在单帧上下说明整条链路没有问题。如果 UploadFrame 动辄 5 毫秒以上大概率是纹理的 Update 方式不对或者行拷贝命中了慢路径。3.4 页面侧调试远程调试端口帮你看清网页内部状态OSR 页面没有可见窗口肉眼没法直接看 CSS 布局这时候可以用 CEF 自带的远程调试能力。初始化时给 CEF 加上一个命令行参数就能开启 DevTools 协议// 在初始化参数里追加调试端口 CefString(settings.arguments).FromString(remote-debugging-port9222);程序跑起来后用任意支持 DevTools Protocol 的浏览器打开 http://localhost:9222就能看到 OSR 页面里的 DOM 结构、Console 日志和网络请求。这个调试入口对排查“页面到底有没有加载”“JS 是不是抛异常了”这类黑匣子问题非常管用。我第一次调 OSR 时页面一直白屏试了半天纹理格式后来用 DevTools 一看是页面里某个资源请求超时导致渲染被阻塞和纹理代码半毛钱关系没有。4. 避坑手册CEF D3D11 离屏渲染的五个常见翻车现场4.1 页面正常加载但合成画面全黑现象是 CEF 的加载事件都触发了音频也正常播放但 D3D11 窗口里一片黑。这种翻车十次有八次是 GPU 合成链路没通。原因是开启 OSR 后Chromium 仍然会尝试创建 GPU 合成器一旦合成失败会退到软件绘制如果软件绘制路径又被禁掉就只剩黑屏。解决方法是先确认三件事初始化参数里没有出现 disable-gpuno_sandbox 已经打开显卡驱动支持 D3D11。逐个都确认过之后再看任务管理器里 GPU 进程是否反复重启。反复重启多半是驱动兼容问题可以临时在参数里加上 disable-gpu-compositing 对比——如果禁用后反而能显示说明问题在 Chromium 的 GPU 合成路径上。4.2 画面显示出来了但颜色红蓝互换现象是整个页面色调诡异红色变蓝色蓝色变红色。原因是 CEF 的 OnPaint 回调输出 BGRA 排列而 D3D11 端建纹理时用了 DXGI_FORMAT_R8G8B8A8_UNORMshader 采样时按 RGBA 顺序解释数据通道全部错位。解决方法是把纹理格式改成 DXGI_FORMAT_B8G8R8A8_UNORM这个格式在 D3D11 里天然支持 BGRA 排列shader 不需要做任何通道交换。注意如果后续要做透明混合BGRA 纹理的 alpha 通道位置在字节流的第四个字节和 RGBA 完全一致不会额外影响透明度计算。4.3 开了 VSync 反而卡顿一卡一卡像掉帧现象是不加 VSync 时画面流畅但会撕裂加了 VSync 后撕裂消失但帧率明显下降而且出现周期性卡顿。原因是 Present(1, 0) 会强制等待垂直同步信号而 OnPaint 回调的到达节奏由页面渲染进程控制两个频率没有对齐。页面每帧 90 FPS 生成屏幕只有 60 Hz多余的帧会在缓冲区里堆积导致显示节奏忽快忽慢。解决方法是把合成节奏和数据产生节奏解耦。D3D11 侧负责按 VSync 时钟抽帧CEF 那边通过 DevTools 或定时器把页面帧率限制在和屏幕刷新率一致的值。更稳的方案是使用 Waitable SwapChain 配合多缓冲让公开 Present 之前先等缓冲区空闲避免 GPU 队列积压。工程第一版用的是最简单的 Present(1, 0)如果要做产品级输出这条改造基本绕不开。4.4 鼠标点击位置偏了尤其高 DPI 缩放后偏差更明显现象是页面渲染位置正确但鼠标点击按钮没反应而且点得越往右下移错位越明显。原因是系统鼠标消息里给的是物理像素而 CEF 的 CefMouseEvent 期望逻辑像素。当窗口位于 150% 缩放的显示器上时物理坐标和逻辑坐标差了 1.5 倍。解决方法是收到鼠标消息后先用设备的 DPI 缩放值把坐标除以缩放倍数再填入事件结构体。同时要让 GetViewRect 返回逻辑尺寸工程里如果在 Windows 上跑可以调用 SetProcessDpiAwarenessContext 统一进程的 DPI 感知模式。排查的时候先在窗口标题栏里打印原始坐标和转换后的坐标对照移动鼠标观察差值半小时就能定位。4.5 程序运行几分钟后显存明显上涨现象是页面动起来后GPU 显存占用持续增长跑得越久越卡。原因是每次 OnPaint 回调都重新创建纹理或者每次都让 D3D11 新建资源旧纹理要等 GPU 不再引用之后才会释放在频繁重绘下驱动里堆积了大量待释放回目。解决方法是实现纹理池。演示工程里最简化的做法是固定创建两到三张相同尺寸的动态纹理一个环形索引轮流使用。下一帧上传前选一张没被较新的绘制引用的纹理Map 写入新数据。这个改动能在不改 D3D11 管线结构的前提下把显存占用压到恒定水平。如果你看到显存涨到几 GB也别慌先加这层池子再做压力测试。5. 二次开发把演示工程改成多实例 OSR 组件真正要把它塞进产品里我建议先把浏览器相关逻辑抽成一个 BrowserInstance 类让每个实例持有自己的 CefBrowser 和 RenderHandler共享同一个 D3D11 设备和上下文。这样多个网页同时渲染时只需要增加 SRV 数组就能在合成端做拼接不用为每个页面重复创建设备资源。改造顺序上我习惯先解决纹理池再挂输入焦点最后处理透明混合每步都跑固定页面验证结果。验证方法是这样准备一个页面放一个不停更新的 Canvas一个 CSS 动画标题一个输入框。重点观察三个指标OnPaint 回调频率是否稳定、UploadFrame 耗时是否恒定、点击输入框时焦点是否正常。如果前两个没问题而输入不跟手优先检查坐标转换和 CefBrowserHost::SetAutoResizeEnabled 的状态。透明页面则在 shader 末尾返回 float4(tex.rgb, tex.a * opacity)再在 D3D11 设备上下文里开启混合状态半透明网页浮层就能叠进游戏画面。第一次把这块拼进自己项目的 UI 层时网页是出来了但整体画面像在打摆子。后来我把 OnPaint 和 Present 彻底解耦让合成节奏完全交给 VSync 驱动渲染器只负责稳定地喂纹理那一版才真正能交付。从那以后我每次接入 CEF 都会先问一句像素是从哪个线程进来的下一帧又是谁在等谁希望帮到你。本文还有配套的精品资源点击获取