无 GPU、无显示器,怎么渲出 4K 图 —— OSMesa + VTK + Qt 离屏渲染的第一性原理
无 GPU、无显示器,怎么渲出 4K 图 —— OSMesa + VTK + Qt 离屏渲染的第一性原理
一条纯 CPU 的 OpenGL 渲染链:Mesa 画像素 → VTK 管场景 → Qt 管输出。全程不碰显卡,不连 X server,在 Docker / CI / SSH 终端里直接导出 4K PNG。
本文顺着这条链路从下往上拆,重点解释几个容易弄反的因果关系:Gallium 到底给离屏渲染带来了什么、为什么离屏窗口要"借"上下文、以及
ReleaseGraphicsResources为什么必须调用两次。
1. 技术栈总览
| 层次 | 组件 | 版本 | 关键构建选项 | 职责 |
|---|---|---|---|---|
| 底层 | Mesa / OSMesa | 19.0 + gallium llvmpipe | -Dosmesa=true -Dgallium-drivers=llvmpipe |
纯 CPU 的 OpenGL 实现,脱离 GPU 和窗口系统完成像素级渲染 |
| 中层 | VTK | 9.2.6(debug + OSMesa + Qt) | VTK_OPENGL_HAS_OSMESA=ON、VTK_USE_X=OFF |
场景图管理 + 离屏窗口抽象 + 像素回读管线 |
| 上层 | Qt | 5.15.3(gcc_64) | 官方预编译包 | 窗口系统桥接(交互模式)+ 叠加层 CPU 合成(两种模式共用) |
三者形成一条自下而上的管线:Mesa 画像素 → VTK 管场景 → Qt 管输出。每一层只解自己那层的问题,上层不感知下层的实现细节(VTK 不关心 GL 调用最终是 GPU 还是 CPU 执行的;Qt 不关心画布来自 VTK 还是别的来源)。
2. Mesa 19.0 / OSMesa —— 没有 GPU 的 OpenGL
2.0 版本前提:OSMesa 有两套实现,本文讲的是 Gallium 那套
容易产生的误解是"Gallium 带来了离屏渲染能力"。实际顺序相反:OSMesa 接口在 1990 年代就存在于 Mesa 中,比 Gallium3D(2008)早十几年。Mesa 历史上有两套 OSMesa 实现:
| classic OSMesa | gallium OSMesa(本项目使用) | |
|---|---|---|
| 代码位置 | src/mesa/drivers/osmesa + src/mesa/swrast |
src/gallium/frontends/osmesa |
| 光栅化后端 | classic swrast | softpipe / llvmpipe |
| 像素落地 | 直接渲染进用户内存(零拷贝) | 渲染在 pipe 自己的资源里,glFlush() 时 copy-out 到用户缓冲区 |
| GL 版本上限 | 2.1 | 4.5 / 4.6 |
| 线程 | 单线程 | Gallium 线程池,多核 |
| 现状 | 已删除(mesa: Retire classic OSMesa) |
Mesa 25.1 起也已移除,理由是与 EGL surfaceless 冗余 |
所以"非 Gallium 的 Mesa 跑不了离屏"在今天成立,但原因是构建依赖而非能力缺失:classic OSMesa 被删除后,meson 选项从三值 ['none','classic','gallium'] 压成布尔 -Dosmesa=true,而 gallium OSMesa 强制要求存在 softpipe 或 llvmpipe;classic swrast DRI 驱动则在 Mesa 21.0 一并删除。于是想得到 libOSMesa.so,只剩 -Dosmesa=true + -Dgallium-drivers=llvmpipe 这一条编译路径。
对 VTK 9.2 还有第二层约束:其 OpenGL2 后端要求 GL 3.2 core / GLSL 1.50,而 classic OSMesa 上限是 GL 2.1 —— 即使它还在,也撑不起这套渲染管线。换言之 llvmpipe 对本项目不是性能选项,是功能下限。
版本风险:OSMesa 已于 Mesa 25.1 从上游移除(仅保留在 amber 分支)。钉在 19.0 是安全的,但若要跨发行版升级,替代路径是 EGL surfaceless 平台 + llvmpipe(对应 VTK 的 vtkEGLRenderWindow)。
2.1 为什么需要 OSMesa?
| 传统 OpenGL 路径 | OSMesa 路径 | |
|---|---|---|
| 创建上下文 | glXCreateContext(需要 X11 Display 连接) |
OSMesaCreateContext(只要一个像素格式 + 共享上下文,不需要 Display 参数,不连任何服务器) |
| 帧缓冲位置 | GPU 显存(通过 DRM/DRI 分配) | 用户态 malloc 的 CPU 内存 |
| 渲染结果获取 | glReadPixels → GPU→CPU DMA 传输 |
glFlush() 时由 llvmpipe copy-out 到用户缓冲区(CPU→CPU memcpy,无 DMA) |
| 依赖项 | X11/Wayland + GPU 驱动 + /dev/dri |
仅依赖 CPU + LLVM |
| 垂直同步 | 受显示器刷新率约束 | 无此概念,渲染完成即返回 |
| 适用场景 | 桌面交互 | CI / Docker / SSH 终端 / 批量导出 |
核心原理:"离屏"实际是两个互相独立的问题,分开看才清楚:
| 问题 | 传统 glX/DRI | OSMesa |
|---|---|---|
| (a) GL 上下文从哪来 | 绑在 X server 上 —— 即使什么都不显示也必须有 DISPLAY,这是 Xvfb 这类"假 X 服务器"存在的全部理由 |
OSMesaCreateContext 不连任何服务器 |
| (b) 像素最终写到哪 | GPU 显存中的帧缓冲 | 用户态 CPU 内存 |
真正卡死 classic 架构的是 (a):classic DRI 驱动是为 DRI/X11 loader 写的,窗口系统耦合在驱动实现内部,想加一条"无窗口系统"的通道要在每个驱动里各写一遍。Gallium 把 (a) 变成一层可替换的 frontend(见 2.2),这才是离屏能力落在 Gallium 身上的架构原因。
OSMesa 本身只是一层很薄的 frontend,它不实现 OpenGL 状态机 —— 状态机在 Mesa 核心 / Gallium state tracker 里,OSMesa 做的事是把"最终写到哪"从窗口系统提供的帧缓冲换成一块用户传入的 CPU 内存。应用调用 OSMesaMakeCurrent(ctx, buffer, ...) 之后,所有 glDrawArrays、glClear、着色器执行都发生在 llvmpipe 自己的离屏资源上,在 glFlush() / glFinish() 时才整块 copy-out 到 buffer。4K RGBA 下这是一次约 32 MB 的 CPU→CPU memcpy —— 远比 GPU→CPU DMA 便宜,但不是零拷贝。
2.2 Gallium3D 驱动框架
| 架构层 | 组件 | 输入 | 输出 | 是否硬件无关 |
|---|---|---|---|---|
| API 前端 | Mesa GLSL 编译器 + API 调度 | OpenGL API 调用 | GLSL IR | 是 |
| State Tracker | Gallium state tracker(src/mesa/state_tracker,2020 年后上游改称 frontend) |
OpenGL 状态变更(glBindTexture、glUseProgram、glEnable ...) |
pipe context state 对象 | 是 |
| 着色器翻译 | Gallium TGSI 编译器(Mesa 19.0 的路径;较新 Mesa 的 llvmpipe 已改走 NIR) | GLSL IR | TGSI 中间表示(类 RISC GPU 汇编) | 是 |
| 驱动接口 | Pipe Driver Interface | TGSI + pipe state | GPU/CPU 可执行码 + draw 命令 | 接口统一,实现各异 |
| 光栅化后端 | llvmpipe / r600 / radeonsi / nouveau / virgl | 接口层的 draw 调用 | 像素 | 否(各自适配不同硬件/软件) |
Gallium 的核心价值:把"怎么做 OpenGL"、"绑哪个窗口系统"和"怎么画像素"三者解耦。上层(state tracker + TGSI)对所有驱动通用;下层(pipe driver)只实现 pipe_context / pipe_screen 这几张函数指针表(量级是数十个回调,不是十几个)。加一个新驱动不需要重写 OpenGL 语义,加一个新 API 不需要改驱动。
中间那一层——窗口系统绑定——才是离屏的关键。src/gallium/frontends/ 下这些是平级的可替换件:
osmesa 和 dri / glx 是同一层的 frontend。换上 osmesa,同一个 llvmpipe 一行不改就跑在无窗口系统模式下 —— 因为 pipe driver 接口里根本没有"窗口"这个概念,只有"给我一块 pipe_resource,往里画"。N 个 API × M 个驱动由乘法变成加法。
| 对比维度 | 无 Gallium(传统 Mesa 驱动) | 有 Gallium |
|---|---|---|
| 代码复用 | 每个驱动独立实现 GL 状态机,大量重复 | State tracker 共享,驱动仅实现 pipe 接口,驱动侧代码量显著小于 classic 驱动 |
| 着色器编译 | 每个驱动各自解析 GLSL、各自做后端优化 | GLSL → TGSI 通用;驱动只实现 TGSI → 目标码 一条链路 |
| 功能升级 | OpenGL 3.3 → 4.5:每个驱动各改一遍 | 改 state tracker 一次,所有 Gallium 驱动受益 |
| 调试 | N 套实现,同一 bug 表现各异 | 管线前半段统一,bug 可复现、可对比 |
2.3 llvmpipe 软件光栅化器
| softpipe(Gallium 参考实现) | llvmpipe(Gallium + LLVM) | |
|---|---|---|
| 着色器执行 | 逐像素解释执行 TGSI 指令 | LLVM JIT 把 TGSI 编译为 x86-64 向量化机器码 |
| 缓存效率 | 全屏扫描,像素反复进出缓存 | Tile-based(64×64),tile 内数据主要留在 L1/L2 |
| 并行策略 | 基本单线程(定位是正确性参考,不做性能优化) | Gallium 线程池分发 tile,多核近线性加速 |
| 性能(4K 场景) | 数分钟量级 | 数秒量级 |
(注:softpipe 是 Gallium 体系内的参考光栅化器,不是 Gallium 之前的 classic swrast;后者已在 Mesa 21.0 删除,见 2.0。)
| 优化技术 | 原理 | 效果 |
|---|---|---|
| LLVM JIT 编译 | TGSI → LLVM IR → 针对当前 CPU 微架构优化 | 片段着色器以接近手写 SIMD 循环速度执行 |
| Tile-based 光栅化 | 屏幕切为 64×64 tile;每 tile 先做三角形包围盒裁剪再光栅化 | 单 tile 的工作集约 32 KB(16 KB 颜色 + 16 KB 深度),与典型 L1d(32–48 KB)同量级,主要命中 L1/L2 而非主存 |
| 多线程分发 | Gallium 框架级线程池;tile 之间无数据依赖,worker 从 bin 队列自取 | 4K(3840×2160)切出 60 × 34 = 2040 个 tile,可同时被所有核处理 |
关键结论:Gallium 切割了驱动栈——上层 API 翻译,中层窗口系统绑定,下层像素生成。llvmpipe 只负责"给 tile 喂三角形、输出 RGBA 像素"这一步。没有 Gallium 的分层,像 llvmpipe 这样功能达到 GL 4.x 的软件渲染器不可能以这个代码量存在 —— classic swrast 同时要背 GL 状态机和 DRI 耦合,最终也只做到 GL 2.1。
2.4 OSMesa 关键 API
| API | 调用者 | 作用 |
|---|---|---|
OSMesaCreateContext(GL_RGBA, NULL) |
vtkOSOpenGLRenderWindow 内部 | 创建无窗 OpenGL 上下文 |
OSMesaMakeCurrent(ctx, buffer, GL_UNSIGNED_BYTE, w, h) |
vtkOSOpenGLRenderWindow 内部 | 把用户分配的内存缓冲区绑定为当前帧缓冲 |
OSMesaGetProcAddress(name) |
VTK GLEW 初始化 | 获取 GL 扩展函数指针(无窗口系统代理) |
OSMesaDestroyContext(ctx) |
vtkOSOpenGLRenderWindow::Finalize | 销毁上下文,释放 GL 资源 |
/* OSMesa 使用模式(VTK 内部等价逻辑) */
OSMesaContext ctx = OSMesaCreateContext(GL_RGBA, NULL);
unsigned char* buffer = malloc(width * height * 4);
OSMesaMakeCurrent(ctx, buffer, GL_UNSIGNED_BYTE, width, height);
/* 以下所有 GL 调用直接写入 buffer */
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glDrawArrays(GL_TRIANGLES, 0, vertex_count);
/* buffer 已是渲染好的 RGBA 像素,无需 glReadPixels */
2.5 运行时环境配置
| 配置项 | 设置值 | 原理 |
|---|---|---|
LD_LIBRARY_PATH |
Mesa 路径在系统路径之前 | VTK 在链接期就把 libOSMesa.so 写进了 DT_NEEDED(不是运行时 dlopen),这里影响的是动态链接器的解析顺序,让它优先命中 llvmpipe 版本的 libOSMesa.so / libGL.so,而不是系统那套面向硬件的实现(后者在创建上下文时会去打开 /dev/dri/renderD128) |
MESA_GLSL_VERSION_OVERRIDE |
150 |
强制 Mesa 接受 GLSL 1.50;VTK 9.2 的 OpenGL2 后端默认生成 GLSL 1.50 代码,而 llvmpipe 某些配置下报告 GLSL 1.30 |
另外本项目的启动脚本里还设了一个
VTK_SILENCE_GET_SUPPORTED_SYSTEMS=1用来压日志。需要说明的是:VTK 上游我只能确认VTK_SILENCE_GET_VOID_POINTER_WARNINGS这个变量,前者在当前版本的源码里没找到对应读取点 —— 可能是历史遗留或内部约定,照搬前建议自行核对。
3. VTK 9.2.6 —— 离屏窗口的抽象与借用
3.1 两种运行模式的窗口对比
| 维度 | 批处理模式(--batch) |
交互模式(GUI) |
|---|---|---|
| 入口 | batch_main.cpp:45 |
main.cpp:46 |
| 源窗口类 | vtkOSOpenGLRenderWindow |
vtkGenericOpenGLRenderWindow |
| GL 上下文来源 | 内部调用 OSMesaCreateContext 自建 |
Qt 通过 QPA 插件创建,由 QVTKOpenGLNativeWidget 持有 |
| Qt 平台插件 | QT_QPA_PLATFORM=offscreen |
默认 xcb(连接 X11) |
| 离屏导出代码 | render_offscreen() |
render_offscreen()(完全相同) |
| 适用环境 | CI / Docker / SSH | 有显示器的桌面 |
3.2 render_offscreen() 九步管线总览
(步骤编号与 3.5 节代码里的 Step N 注释一一对应。)
| 步骤 | 操作 | 关键 API | 为何必须这样做 |
|---|---|---|---|
| 1. 借上下文 | 把源窗口的 GL 上下文绑定到当前线程 | source->MakeCurrent() |
OpenGL 是线程局部状态机;离屏窗口不创建自己的上下文(SetOwnContext(0)),必须寄生在借来的上下文上 |
| 2. 保存 FBO | 记录当前 Draw/Read FBO 绑定 | glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, ...) |
渲染完成后原样恢复,否则离屏内容泄漏到屏幕控件 |
| 3. 建离屏窗口 | 创建 GenericOpenGLRenderWindow,设 NoBlit + 不映射 | SetOwnContext(0)、SetFrameBlitModeToNoBlit() |
NoBlit 保证渲染结果留在离屏 FBO 不往外写;不映射避免尝试连接窗口系统 |
| 4. 克隆渲染器 | 深拷贝相机 + 共享 prop + 共享光源 | DeepCopy(cam)、AddViewProp(prop) |
相机深拷贝:宽高比变化会改写视锥参数;prop 共享:几何数据深拷贝代价不可接受 |
| 5. 渲染前释放 | 释放 prop 当前(源窗口侧)的 GL 资源 | prop->ReleaseGraphicsResources(source) |
mapper 同一时刻只能为一个窗口持有 GL 资源;不释放的话新窗口渲染时会与残留资源冲突 |
| 6. 离屏渲染 + 回读 | 执行完整 VTK 管线,再用 WindowToImage 取像素 | offscreen->Render() + vtkWindowToImageFilter |
场景图遍历→裁剪→排序→着色器执行(llvmpipe JIT 码在 CPU 上跑)→glReadPixels(OSMesa 下是 CPU 侧拷贝) |
| 7. 渲染后释放 | 释放 prop 在离屏窗口侧的 GL 资源 | prop->ReleaseGraphicsResources(offscreen) |
离屏窗口即将析构;不先释放会让 prop 带着指向离屏窗口的资源状态回到交互渲染 |
| 8. 像素倒搬 | 逐行倒序拷贝,VTK(左下原点)→ QImage(左上原点) | std::memcpy(image.scanLine(y), row, row_bytes) |
OpenGL 与屏幕坐标系 Y 轴方向相反 |
| 9. 拆解 + 还原 | 移除 Prop/Light/Renderer→Finalize→还原 FBO | RemoveAllViewProps()、Finalize()、glBindFramebuffer(...) |
移除顺序不可颠倒:先清 prop 再拆 renderer,否则 renderer 析构时会去碰已半清理的对象 |
3.3 克隆策略详解
| 对象 | 策略 | API | 代价 | 原因 |
|---|---|---|---|---|
| 相机 | 深拷贝 | vtkCamera::DeepCopy() |
几百字节量级的矩阵与标量参数拷贝(微秒级) | 渲染过程中 VTK 按目标宽高比改写视锥参数(UseHorizontalViewAngle 等);共享实例会把改写带回在屏窗口,导致画面变形 |
| Prop(Actor/Volume) | 共享实例 | clone->AddViewProp(prop) |
渲染前后各一次 ReleaseGraphicsResources | 几何数据(几十万至几千万三角形)深拷贝代价不可接受(秒级耗时 + GB 级内存) |
| 光源 | 共享实例 | clone->AddLight(light) |
无 | 光源的位置/颜色/强度等参数在渲染中只被读取。唯一需要留意的是 camera light:vtkRenderer::UpdateLightsGeometryToFollowCamera() 会按当前相机写 light 的变换矩阵;但克隆相机是深拷贝的,视图矩阵与源相机一致(只有投影矩阵因宽高比不同),写入值相同,因此共享在本场景下仍然安全 |
3.4 ReleaseGraphicsResources 的双次调用原理
先纠正一个容易产生的模型错误:VTK 的 mapper 不维护一张以 vtkRenderWindow* 为 key 的多窗口资源映射表。vtkOpenGLPolyDataMapper 在任一时刻只持有一套 GL 资源(VBO group、IBO、以及从所属窗口的 vtkOpenGLShaderCache 里拿到的 program),这套资源隐式归属于"上次渲染它的那个窗口"。
因此 ReleaseGraphicsResources(win) 做的是真正的释放——它调用 glDeleteBuffers / glDeleteTextures / 解除 program 引用,把这套资源拆掉,而不是"删掉一条映射记录"。双次调用的本质是把 prop 在两个窗口之间交接两次,每次交接都要先把上一任的资源拆干净:
| 时机 | 调用 | 目的 |
|---|---|---|
| 渲染前 | prop->ReleaseGraphicsResources(source) |
拆掉 prop 为源窗口建立的 VBO/IBO/纹理/program,使它在离屏 Render() 中按离屏窗口的尺寸与 shader cache 重新构建一套 |
| 渲染后 | prop->ReleaseGraphicsResources(offscreen) |
拆掉离屏渲染期间建立的那一套。离屏窗口随后就 Finalize() 并析构,如果不先拆,prop 会带着引用离屏窗口 shader cache 的 program 回到交互渲染 |
注意两个窗口共享同一 GL 上下文,GL 对象的 namespace 是上下文级的,而 VTK 的 shader cache、state 跟踪是窗口级的。正是这个错配使得"同一个 prop 被两个窗口先后渲染"必须显式交接,否则窗口级的记账与上下文级的真实对象会对不上。
3.5 render_offscreen() 逐行注释
// ====================================================================
// render_offscreen(vtkRenderWindow* source, int width, int height)
// ====================================================================
// 把源窗口的 VTK 场景按指定分辨率离屏重渲为 QImage(RGB888)。
// 核心思想:借用源窗口的 GL 上下文,创建一个寄生式的离屏窗口,
// 克隆渲染器(相机深拷贝、prop 共享),渲染后回读像素并清理。
//
// 调用链:mainwindow.cpp:112 / batch_main.cpp:33 → 本函数 → Qt compose()
// ====================================================================
QImage render_offscreen(vtkRenderWindow* source, int width, int height) {
// ---- 参数守卫 --------------------------------------------------
// nullptr 窗口、零/负尺寸均无意义,直接返回空 QImage。
// 调用方(doExport / do_export_batch)已通过 check_resolution
// 做过分辨率校验,这里是最后一道防线。
if (source == nullptr || width <= 0 || height <= 0) {
return QImage{};
}
// ================================================================
// Step 1 — 借源窗口的 GL 上下文
// ================================================================
// OpenGL 是线程局部状态机:每个线程有一个"当前上下文",
// 后续所有 gl* 调用隐式操作在它上面。
//
// MakeCurrent() 的底层实现取决于源窗口的类型:
// 交互模式 vtkGenericOpenGLRenderWindow(Qt 持有上下文)
// → glXMakeCurrent(经 Mesa libGL)
// 批处理模式 vtkOSOpenGLRenderWindow
// → OSMesaMakeCurrent(ctx, buffer, GL_UNSIGNED_BYTE, w, h)
// 两种情况都把 source 所关联的 GL 上下文绑定到当前线程。
//
// 必须在创建离屏窗口之前调用,因为离屏窗口设了
// SetOwnContext(0),不会自己创建上下文——它寄生在这里。
source->MakeCurrent();
// ================================================================
// Step 2 — 保存当前 FBO 绑定
// ================================================================
// FBO(Framebuffer Object)决定了 GL 渲染的输出目标。
// 两个绑定各有用处:
// DRAW_FRAMEBUFFER — glClear / glDraw* 的写入目标
// READ_FRAMEBUFFER — glReadPixels / glCopyTexImage 的读取源
//
// 渲染完成后必须原样恢复这些值。不恢复的话:
// - 交互模式:后续 Qt 控件的渲染会写到离屏窗口的 FBO
// (因为 MakeCurrent 后上下文仍是同一个)
// - 最坏情况:离屏 FBO 析构后,之前记录的 FBO 名变成悬空编号,
// 后续 glBindFramebuffer 会静默绑定到一个无效目标(GL 不报错,
// 但渲染结果去向不可知)
GLint prev_draw_fbo = 0, prev_read_fbo = 0;
glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &prev_draw_fbo);
glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prev_read_fbo);
// ================================================================
// Step 3 — 创建寄生的离屏窗口
// ================================================================
auto offscreen = vtkSmartPointer<vtkGenericOpenGLRenderWindow>::New();
// SetIsDirect(1): 声称支持直接渲染(与源窗口一致)。
// 对 OSMesa/llvmpipe 这只是一个标记,不影响实际渲染路径。
offscreen->SetIsDirect(1);
// SetSupportsOpenGL(1): 告诉 VTK 此窗口支持 OpenGL。
// 这是 vtkGenericOpenGLRenderWindow 的"外部代答"机制:它自己不创建
// 上下文,无法探测能力,只能由调用方声明。
// 不设置时 VTK 认为该窗口没有可用的 OpenGL,Render() 会走失败路径
// 并报 "GL version 2.1 with the gpu_shader4 extension is not supported"
// 一类错误 —— 注意 VTK 9.x 已经没有 OpenGL1 固定管线后端可退回
// (vtkRenderingOpenGL 模块在 VTK 8.2/9.0 期间被移除),所以这里
// 不是"降级渲染",而是"不渲染"。
offscreen->SetSupportsOpenGL(1);
// SetIsCurrent(true): 声明 MakeCurrent 已完成(步骤 1 已做)。
// VTK 收到此标记后跳过自己的 MakeCurrent 调用。
offscreen->SetIsCurrent(true);
// SetReadyForRendering(true): 允许 Render() 实际执行。
// false 时 Render() 是空操作——用于窗口尚未完全初始化的过渡期。
offscreen->SetReadyForRendering(true);
// ★ SetOwnContext(0): 这是整个寄生策略的核心开关。
// 0 = 不创建自己的 GL 上下文,使用外供的。
// 1 = 创建独立的 GL 上下文(会走 OSMesaCreateContext 或 glXCreateContext)。
offscreen->SetOwnContext(0);
// SetSize(width, height): 目标像素尺寸。
// 决定了离屏窗口内部 FBO 的分辨率、glViewport 尺寸、
// 以及相机视锥参数的重计算基准。
offscreen->SetSize(width, height);
// SetMapped(0): 不映射到屏幕(批处理模式下根本没有屏幕)。
// 同时也会影响 VTK 的渲染策略——未映射窗口不走 swap/swapBuffers。
offscreen->SetMapped(0);
// ★ SetFrameBlitModeToNoBlit(): 这是最重要的 FBO 策略开关。
// VTK 默认行为(BlitToCurrent):
// 每帧渲染后,把离屏 FBO 的内容通过 glBlitFramebuffer
// 写到 MakeCurrent 时激活的 draw FBO(即源窗口的 FBO)。
// 结果:交互模式下离屏渲染内容会"闪现"到屏幕控件。
//
// NoBlit:
// 渲染结果停留在离屏窗口的 FBO 中,不往外写。
// 仅通过后续的 vtkWindowToImageFilter::glReadPixels 读取。
offscreen->SetFrameBlitModeToNoBlit();
// 对齐层数和多重采样设置,确保克隆的渲染管线参数一致。
offscreen->SetNumberOfLayers(source->GetNumberOfLayers());
offscreen->SetMultiSamples(source->GetMultiSamples());
// ================================================================
// Step 4 — 克隆渲染器:场景内容从源窗口迁移到离屏窗口
// ================================================================
// 三条策略:
// 1. 相机 → 深拷贝(下面详述)
// 2. Prop → 共享实例(不拷贝几何数据)
// 3. 光源 → 共享实例(渲染中不会被改写)
//
// shared_props 数组收集所有被共享的 prop 指针,
// 用于后续的双次 ReleaseGraphicsResources。
// cam_copies 去重 map:
// 同一个 vtkCamera* 可能被多个 renderer 共用(如 bg + main
// 共享一个透视相机)。用 map 保证每个源相机只深拷贝一次,
// 多个 renderer 的副本也用同一份拷贝。
std::map<vtkCamera*, vtkSmartPointer<vtkCamera>> cam_copies;
std::vector<vtkSmartPointer<vtkRenderer>> clones;
std::vector<vtkProp*> shared_props;
// 遍历源窗口的所有 renderer(本 demo 中是 bg/main/fg 三层)。
vtkRendererCollection* renderers = source->GetRenderers();
renderers->InitTraversal();
while (vtkRenderer* src = renderers->GetNextItem()) {
auto dst = vtkSmartPointer<vtkRenderer>::New();
// ---- 复制渲染参数 ----
// Layer: 0=背景, 1=几何, 2=前景;决定渲染顺序。
dst->SetLayer(src->GetLayer());
// Viewport: 归一化坐标 [0,1];控制渲染器在窗口中的子区域。
dst->SetViewport(src->GetViewport());
// 背景色:逐层透明叠加时,下层颜色透过上层 alpha 通道可见。
dst->SetBackground(src->GetBackground());
dst->SetBackground2(src->GetBackground2());
dst->SetGradientBackground(src->GetGradientBackground());
// BackgroundAlpha: 本 demo 的 main/fg 层设 0(透明),
// 让下层的渐变背景或几何体透上来。
dst->SetBackgroundAlpha(src->GetBackgroundAlpha());
// ---- 相机:深拷贝 ----
// 为什么必须深拷贝?
// VTK 在 Render() 时会调用 vtkOpenGLCamera::Render(),
// 该函数根据渲染窗口的宽高比(width/height)重算投影矩阵。
// 离屏窗口尺寸(如 3840x2160)≠ 源窗口尺寸(如 800x600),
// 宽高比不同 → 视锥参数不同。
//
// 如果共享相机实例(浅拷贝),重算后的投影矩阵会覆盖
// 源窗口的相机状态。用户回到交互窗口时,画面被压扁或拉伸。
//
// 深拷贝 vtkCamera::DeepCopy() 复制:
// - Position / FocalPoint / ViewUp(视图矩阵)
// - ViewAngle / ClippingRange / ParallelScale(投影矩阵参数)
// - 但不复制任何纹理或 VBO(相机也没有这些)
// 代价:约 200 字节的内存拷贝,微秒级。
if (vtkCamera* src_cam = src->GetActiveCamera()) {
auto it = cam_copies.find(src_cam);
if (it == cam_copies.end()) {
// 第一次遇到此相机,深拷贝
auto copy = vtkSmartPointer<vtkCamera>::New();
copy->DeepCopy(src_cam);
it = cam_copies.emplace(src_cam, copy).first;
// emplace 返回 pair<iterator, true>,iterator 指向新条目
}
dst->SetActiveCamera(it->second);
// it->second 可能是刚创建的拷贝,也可能是之前为另一个
// renderer 创建的共享拷贝(同一个源相机 → 同一个拷贝)
}
// ---- 光源:共享实例 ----
// 光源属性(位置、颜色、强度、衰减系数)在 Render() 期间
// 被 VTK 读取并送入着色器 uniform,但不会被改写。
// 因此共享是安全的——不需要拷贝。
if (vtkLightCollection* lights = src->GetLights()) {
lights->InitTraversal();
while (vtkLight* light = lights->GetNextItem()) {
dst->AddLight(light); // 共享指针,不拷贝
}
}
// ---- Prop:共享实例 ----
// Actor(锥体、球体)、TextActor、Volume 都属于 vtkProp。
// 每个 prop 里是 mapper → 几何数据(vtkPolyData,几万~几千万
// 三角形)→ GPU 端 VBO/IBO。
//
// 如果深拷贝几何数据:
// - 时间:几十万三角形的 DeepCopy 在秒级
// - 内存:单个大型场景可能是 GB 级
// → 完全不可接受
//
// 共享的代价:
// 一个 prop 的 mapper 在任一时刻只能为一个窗口持有 GL 资源
// (VBO/IBO + 该窗口 shader cache 里的 program)。
// 所以需要双次 ReleaseGraphicsResources 把它在两个窗口之间
// 交接过去再交接回来(见 Step 5 / Step 7)。
if (vtkPropCollection* props = src->GetViewProps()) {
props->InitTraversal();
while (vtkProp* prop = props->GetNextProp()) {
dst->AddViewProp(prop); // 共享指针,不拷贝
shared_props.push_back(prop); // 记账,后续释放用
}
}
// 把克隆好的 renderer 挂到离屏窗口上
offscreen->AddRenderer(dst);
clones.push_back(dst); // 记账,后续清理用
}
// ================================================================
// Step 5 — 渲染前:释放 prop 在源窗口侧的 GL 资源
// ================================================================
// vtkOpenGLPolyDataMapper(Prop 内部的 mapper)在任一时刻只持有
// 一套 GL 资源:
// VBO group / IBO(glGenBuffers 得到的整数名)
// 纹理对象
// 从所属窗口的 vtkOpenGLShaderCache 取得的 shader program
// 它并不按 vtkRenderWindow* 维护多份映射 —— 这套资源隐式归属于
// "上次渲染它的那个窗口"。
//
// 错配点:GL 对象名的 namespace 是【上下文级】的,而 VTK 的
// shader cache 和 state 跟踪是【窗口级】的。两个窗口共用一个
// 上下文时,这个错配必须靠显式交接来抹平。
//
// ReleaseGraphicsResources(source) 是【真正的释放】,不是"清一条
// 映射记录":
// 逐个 glDeleteBuffers / glDeleteTextures、解除 program 引用,
// 把这套资源拆掉,mapper 回到"未构建"状态。
//
// 如果不做这一步:
// 离屏 Render() 会在 prop 仍持有源窗口资源的情况下继续,
// program 来自源窗口的 shader cache,而 VBO 的尺寸/状态是按
// 源窗口建立的 → 资源归属混乱,典型表现是离屏图渲染不完整,
// 或回到交互模式后在屏画面出现异常。
for (auto* prop : shared_props) {
prop->ReleaseGraphicsResources(source);
}
// ================================================================
// Step 6 — 离屏渲染 + 像素回读
// ================================================================
// VTK debug 版会在首次着色器编译时往 stdout 打 GLSL 代码。
// 对于批处理模式,这些输出会污染日志。临时把 stdout 重定向到
// /dev/null,渲染完再恢复。
//
// dup/dup2 机制:
// dup(STDOUT_FILENO) → 复制 fd 1 到新 fd(如 3),saved=3
// open("/dev/null") → 打开 /dev/null,得到 fd 4
// dup2(null_fd, 1) → 把 fd 4 复制到 fd 1(stdout 被重定向)
// close(null_fd) → 关闭 fd 4(fd 1 仍指向 /dev/null)
// ... Render() ...
// dup2(saved, 1) → 把 saved(fd 3) 复制回 fd 1(stdout 恢复)
// close(saved) → 关闭临时 fd 3
fflush(stdout); // 先刷新,不丢已缓冲的输出
int saved = dup(STDOUT_FILENO); // 备份 stdout fd
int null_fd = open("/dev/null", O_WRONLY); // 打开黑洞
dup2(null_fd, STDOUT_FILENO); // 重定向 stdout → /dev/null
close(null_fd); // 不再需要原始 null fd
// ★ 实际渲染 —— 触发完整 VTK 管线
offscreen->Render();
// 内部等价于:
// vtkOpenGLRenderWindow::Render()
// ├─ vtkOpenGLRenderWindow::OpenGLInit()
// │ 创建/检查 FBO、深度缓冲、模板缓冲(尺寸 = width×height)
// │ 这些 GL 对象由离屏窗口自己的 OpenGL 后端管理
// ├─ for each renderer (按 layer 0→1→2):
// │ vtkOpenGLRenderer::DeviceRender()
// │ ├─ glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
// │ │ 清空当前 FBO(即离屏窗口的 FBO)
// │ ├─ vtkOpenGLCamera::Render()
// │ │ 根据 width/height 重算投影矩阵 → 上传 uniform
// │ │ (这就是相机不能共享的根本原因)
// │ ├─ for each prop (Actor → TextActor → ...):
// │ │ vtkOpenGLActor::Render()
// │ │ ├─ 上传 model/view/projection 矩阵
// │ │ ├─ 绑定 VBO/IBO(glBindBuffer)
// │ │ ├─ 上传材质 uniform(颜色、高光、环境光)
// │ │ ├─ 绑定并激活着色器程序
// │ │ └─ glDrawElements / glDrawArrays
// │ │ (在 OSMesa/llvmpipe 下,调用的是
// │ │ llvmpipe 的 JIT 编译码,在 CPU 上光栅化)
// │ └─ ... 下一层 renderer
// └─ 渲染结果在离屏窗口的 FBO 中(CPU 内存里的一块 RGBA 缓冲)
fflush(stdout); // 刷新可能残留的输出
dup2(saved, STDOUT_FILENO); // 恢复 stdout
close(saved); // 关闭备份 fd
// ---- 像素回读 ----
// vtkWindowToImageFilter 封装了 glReadPixels 调用。
auto grabber = vtkSmartPointer<vtkWindowToImageFilter>::New();
grabber->SetInput(offscreen); // 指定要读的窗口
grabber->SetInputBufferTypeToRGB(); // 读 RGB(不要 alpha)
grabber->ReadFrontBufferOff(); // 读后缓冲(真正渲染的目标)
// 前缓冲是显示用的(double-buffer 机制)
grabber->Update(); // 触发实际 glReadPixels
// ---- 像素从 vtkImageData 倒搬到 QImage ----
QImage image;
if (vtkImageData* data = grabber->GetOutput()) {
int dims[3] = {};
data->GetDimensions(dims); // dims = {width, height, 1}
if (dims[0] > 0 && dims[1] > 0) {
// QImage::Format_RGB888: 每像素 3 字节,R/G/B 各 8 bit,无 alpha。
// scanLine(y) 返回第 y 行首像素的指针(每行连续排列)。
image = QImage(dims[0], dims[1], QImage::Format_RGB888);
int row_bytes = dims[0] * 3; // 每行 3 × width 字节
for (int y = 0; y < dims[1]; ++y) {
// ★ 关键:Y 轴翻转
// VTK: y=0 = 左下角(OpenGL 约定)
// QImage: y=0 = 左上角(屏幕/图像约定)
// → 源行索引 = dims[1] - 1 - y,目标行索引 = y
const unsigned char* row =
static_cast<const unsigned char*>(
data->GetScalarPointer(0, dims[1] - 1 - y, 0));
// GetScalarPointer(x, y, z) 返回像素 (x,y,z) 的指针。
// dims[1]-1-y 实现了垂直翻转:读最底下一行→写到最顶上一行。
std::memcpy(image.scanLine(y), row, row_bytes);
// 整行 memcpy,由 libc 的向量化实现处理(具体走 SSE/AVX
// 还是 ERMSB 取决于 glibc 对当前 CPU 的 dispatch)。
// 4K 下共 2160 次、合计约 25 MB 的 CPU→CPU 拷贝。
}
}
}
// ================================================================
// Step 7 — 渲染后:释放 prop 在离屏窗口侧的 GL 资源
// ================================================================
// 离屏窗口即将析构(离开作用域后 vtkSmartPointer 会销毁它)。
// 此刻 shared_props 持有的那一套 GL 资源,是步骤 6 的 Render() 中
// 为【离屏窗口】重新构建的:VBO/IBO 由 glGenBuffers 新分配,
// shader program 来自【离屏窗口】的 vtkOpenGLShaderCache。
//
// 如果不在这里主动释放:
// 离屏窗口 Finalize()/析构会连带销毁它自己的 shader cache 与
// state 对象,而 prop 仍指向其中的 program。回到交互模式后,
// 下一次在屏 Render() 会拿着这些已失效的引用去渲染。
//
// 所以必须在离屏窗口还活着的时候调用
// ReleaseGraphicsResources(offscreen),把这套资源真正删掉
// (glDeleteBuffers 等),让 prop 回到"未构建"状态——下次在屏
// Render() 时它会为 source 窗口干净地重建一套。
//
// 对称性:步骤 5 是"交接给离屏窗口前先拆",这里是"交接回源窗口
// 前先拆"。两次调用是同一个动作的两个方向,不是一次释放加一次
// 清记账。
for (auto* prop : shared_props) {
prop->ReleaseGraphicsResources(offscreen);
}
// ================================================================
// Step 8 — 拆解离屏窗口:清理引用,防止析构时踩坏共享数据
// ================================================================
// ★ 顺序极为重要 ★
//
// 1. RemoveAllViewProps() 必须在 RemoveRenderer() 之前:
// 先让 prop 从 renderer 的 prop 列表中移除。
// 如果顺序反了,RemoveRenderer 时 renderer 析构,
// 析构函数可能遍历 prop 列表尝试 ReleaseGraphicsResources,
// 而此时 prop 已处于半清理状态 → 未定义行为。
//
// 2. RemoveAllLights() 同理。
//
// 3. RemoveRenderer(clone) 把克隆 renderer 从离屏窗口的 renderer
// 列表中移除。所有 clone renderer 移除后,离屏窗口不再持有任何
// 对 shared_props/shared_lights 的引用。
//
// 4. Finalize() 释放离屏窗口自己的 GL 资源(FBO、深度缓冲等)。
// 此时 shared_props 的引用已清除,Finalize 不会误删它们。
//
// 5. SetReadyForRendering(false) 阻止离屏窗口的析构函数再次尝试
// 渲染或释放 GPU 资源。
for (auto& clone : clones) {
clone->RemoveAllViewProps(); // ★ 先拆 prop
clone->RemoveAllLights(); // 再拆光源
}
for (auto& clone : clones) {
offscreen->RemoveRenderer(clone); // 再从窗口移除 renderer
}
offscreen->Finalize(); // 释放离屏窗口自有 GL 资源
offscreen->SetReadyForRendering(false); // 阻止析构函数再次渲染
// ================================================================
// Step 9 — 还原 FBO 绑定
// ================================================================
// 把步骤 2 保存的 FBO 绑定原样恢复。
// 这样调用方代码后续的 GL 操作(如 Qt 控件的下一次 paintGL)
// 仍然能正确渲染到屏幕控件上。
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, prev_draw_fbo);
glBindFramebuffer(GL_READ_FRAMEBUFFER, prev_read_fbo);
// 返回渲染好的图像。
// 如果中间任何步骤失败(如 FBO 分配失败、glReadPixels 返回空),
// image 是 isNull()==true 的空 QImage,调用方会报错。
return image;
}
4. Qt 5.15.3 —— 桥接与合成
4.1 QVTKOpenGLNativeWidget 上下文桥接
| 步骤 | 操作 | 底层调用 |
|---|---|---|
| 1 | Qt 通过 xcb QPA 插件创建 native window surface | XCreateWindow |
| 2 | Qt 在 surface 上创建 GL 上下文 | glXCreateContextAttribsARB(走 Mesa libGL) |
| 3 | 把 VTK 窗口挂到 Qt 控件上 | setRenderWindow(vtkGenericOpenGLRenderWindow) |
| 4 | VTK 所有 GL 调用走 Qt 的上下文 | 渲染结果直接显示在 Qt 控件上 |
关键点:QVTKOpenGLNativeWidget 持有的 GL 上下文,同时也是 render_offscreen() 步骤 1 中借用的上下文。这是交互模式导出无需另建 OSMesa 上下文的根本原因。
这张表只适用于交互模式。 批处理模式下不存在这条桥接链:GL 上下文由 vtkOSOpenGLRenderWindow 自己通过 OSMesaCreateContext 创建,Qt 完全不参与上下文的创建(见 4.2)。
4.2 两种 QPA 平台对比
| 维度 | xcb(默认) | offscreen(QT_QPA_PLATFORM=offscreen) |
|---|---|---|
| 加载的插件 | libqxcb.so |
libqoffscreen.so |
| 窗口创建 | XCreateWindow(需 X11 服务器) |
纯内存 pixel buffer,不连任何显示服务器 |
| OpenGL 支持 | 通过 glX(X11 GLX 扩展)提供给 Qt 控件 | 不提供(Qt 5.15 的 offscreen 插件若要 GL,走的是 QOffscreenX11GLXIntegration,那条路仍需 X11) |
| 事件循环 | X11 事件队列 | 纯内存事件队列,QApplication::exec 正常工作 |
| 适用场景 | 桌面交互 | SSH 终端 / Docker / CI runner |
这里有个容易弄反的分工:批处理模式设 QT_QPA_PLATFORM=offscreen 不是为了让 Qt 提供离屏 GL —— Qt 的 offscreen 插件给不了 GL 上下文。它的作用只是让 QApplication、QFont、QPainter 这些需要"一个平台后端存在"的设施在没有 X11 的机器上能正常构造,从而让 4.3 的 CPU 合成跑得起来。
GL 上下文另有来源:vtkOSOpenGLRenderWindow 直接调 OSMesaCreateContext,完全绕过 QPA。两件事各走各路,这也是为什么批处理模式下"Qt 平台插件"和"GL 上下文来源"在 3.1 表里是两行而不是一行。
4.3 QPainter 叠加层合成
| 层序 | 操作 | API | 执行位置 |
|---|---|---|---|
| 底层 | 填充背景色 | QImage::fill(bgColor) |
CPU 内存 memset |
| 中层 | 贴 VTK 画布 | QPainter::drawImage(0, 0, canvas) |
CPU memcpy(RGB→RGB32 可能含格式转换) |
| 顶层 | 画边界框 + 四角加强线 | QPainter::drawRect + drawLine ×8 |
CPU 2D 光栅化 |
| 顶层 | 画标题(右下角) | QPainter::drawText(sans-serif bold 18pt) |
CPU 字形光栅化 |
| 顶层 | 画信息行(左上角) | QPainter::drawText(monospace 11pt) |
CPU 字形光栅化 |
合成全过程在 CPU 侧完成。量级估算:4K 的 RGB32 画布是 3840 × 2160 × 4 = 约 33 MB,fill() 一次全画布 memset、drawImage() 一次带 RGB888→RGB32 转换的全画布遍历,按单核内存带宽数 GB/s 算,各自毫秒级,合计约 10 ms 量级;边框和两处文字的光栅化只覆盖少量像素,可忽略。相对于 llvmpipe 渲染 4K 的数秒,合成开销不到 1%——但它不是"微秒级"。
5. 完整渲染链路
以批处理模式导出 3840×2160 4K 图像为例:
| 阶段 | 组件 | 关键操作 | 输出 |
|---|---|---|---|
| 1. 平台初始化 | Qt | QT_QPA_PLATFORM=offscreen |
纯内存 QPA 后端就绪 |
| 2. GL 上下文创建 | VTK + Mesa | vtkOSOpenGLRenderWindow::New() → OSMesaCreateContext() |
llvmpipe 驱动初始化,GL 上下文就绪 |
| 3. 场景构建 | VTK | build_scene():三层 renderer + 锥体/球体/文本 |
场景图挂到源窗口;首次 Render 触发着色器 LLVM JIT 编译 |
| 4. 离屏重渲 | VTK + Mesa | render_offscreen(source, 3840, 2160):借上下文 → 克隆 renderer → 双次 ReleaseGraphicsResources → Render(llvmpipe 在 3840×2160 FBO 执行 JIT 着色器)→ vtkWindowToImageFilter 回读 |
QImage(RGB888,3840×2160) |
| 5. 合成输出 | Qt | compose():底填色 + 贴 VTK 画布 + QPainter 叠加层 |
PNG 文件写入磁盘 |
全程无 GPU 硬件参与。 这是离屏渲染方案能无头运行的物理基础。
6. 分辨率校验
| 限制项 | 上限 | 依据 |
|---|---|---|
| 单边最大值 | 16384 | 这不是 OpenGL 规范规定的值。 规范把 GL_MAX_VIEWPORT_DIMS / GL_MAX_RENDERBUFFER_SIZE / GL_MAX_TEXTURE_SIZE 都定义为实现相关,只规定下限。16384 是 llvmpipe 等常见实现的实际上限,被这里取作硬编码安全界 |
| 单边最小值 | 1 | 零或负尺寸无意义 |
| 总像素上限 | 64,000,000 | ~8000×8000 量级。llvmpipe 在此量级下要分配约 256 MB 颜色缓冲(64M × 4 B),加上深度/模板再约 256 MB,合计 ~512 MB;执行时间数十秒至数分钟。超过此值更可能是参数填错而非真实需求 |
可以改进的地方:16384 这个硬编码界只在 llvmpipe 上恰好正确。更稳妥的做法是在上下文就绪后运行时查询,取实际值与硬编码界的较小者:
GLint dims[2] = {0, 0};
glGetIntegerv(GL_MAX_VIEWPORT_DIMS, dims);
GLint max_rb = 0;
glGetIntegerv(GL_MAX_RENDERBUFFER_SIZE, &max_rb);
const int hard_limit = std::min({dims[0], dims[1], max_rb, MAX_SIDE});
// 注意:必须在 MakeCurrent 之后调用,否则查到的是 0
7. 关键设计决策
| 决策 | 做法 | 替代方案 | 为什么不用替代方案 |
|---|---|---|---|
| 相机处理 | 深拷贝 | 共享实例,渲染后手动恢复视锥参数 | VTK 内部改写相机参数的位置分散在多处,逐一恢复脆弱且不可维护 |
| Prop 处理 | 共享实例 + 双次 ReleaseGraphicsResources | 深拷贝几何数据 | 几千万三角形的拷贝在时间和内存上都不可接受 |
| GL 上下文 | 借用源窗口已有上下文 | 创建第二个上下文 + shared context list | 额外复杂度;shared context 在不同驱动上行为不完全一致 |
| FBO 策略 | SetFrameBlitModeToNoBlit + 保存/恢复 | 不设 NoBlit,事后用 glClear 擦除屏幕 FBO | 有一帧的时间窗口内屏幕显示离屏渲染内容(画面闪烁) |
| 缩放策略 | 拒绝不匹配的尺寸 | 自动缩放画布凑齐目标尺寸 | 缩放下采样损失细小标注和网格线;上采样引入模糊 |

浙公网安备 33010602011771号