无 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/ 下这些是平级的可替换件:

%%{init: {"theme":"neutral","flowchart":{"useMaxWidth":false,"htmlLabels":true}}}%% flowchart TB subgraph FE["frontends —— 可替换件:决定「绑哪个窗口系统」"] direction LR dri["dri"] glx["glx"] egl["egl"] osm["osmesa ★ 本文路径"] vdpau["vdpau"] va["va"] nine["nine"] clover["clover"] end subgraph PD["pipe drivers —— 决定「像素怎么生成」"] direction LR llvm["llvmpipe ★ 本文路径"] soft["softpipe"] rsi["radeonsi"] iris["iris"] zink["zink"] end FE -. "pipe driver interface<br/>pipe_screen / pipe_context<br/>(接口里没有「窗口」这个概念)" .-> PD

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 有一帧的时间窗口内屏幕显示离屏渲染内容(画面闪烁)
缩放策略 拒绝不匹配的尺寸 自动缩放画布凑齐目标尺寸 缩放下采样损失细小标注和网格线;上采样引入模糊

8. 附图

8.1 模块与依赖

%%{init: {"theme":"neutral","flowchart":{"useMaxWidth":false,"htmlLabels":true}}}%% flowchart LR subgraph APP["应用模块"] direction TB main["<b>main</b><br/>main.cpp<br/>入口:建源窗口 + 导出"] res["<b>resolution</b><br/>resolution.h<br/>kMinEdge / kMaxEdge / kMaxPixels<br/>check_resolution(w, h)"] sb["<b>scene_builder</b><br/>scene_builder.h<br/>Scene:bg / main / fg / cone / sphere<br/>build_scene(...)"] re["<b>render_engine</b><br/>render_engine.h<br/>render_offscreen(src, w, h) → QImage<br/>克隆 renderer / camera"] qo["<b>qt_overlay</b><br/>qt_overlay.h<br/>OverlayState / paint_overlay()<br/>compose(QImage, state, ...)"] end subgraph VTKW["VTK 渲染窗口"] direction TB vos["<b>vtkOSOpenGLRenderWindow</b><br/>源窗口 · 自建 OSMesa 上下文"] vgen["<b>vtkGenericOpenGLRenderWindow</b><br/>离屏副本 · 借上下文 · 独立 FBO"] end subgraph QTC["Qt 画布"] qimg["<b>QImage / QPainter</b><br/>Qt 5.15 纯 CPU 离屏"] end main -->|校验| res main -->|搭场景| sb main -->|离屏重渲| re main -->|Qt 合成| qo sb -->|AddRenderer ×3 层| vos re -->|New + Render| vgen re -->|MakeCurrent + 克隆| vos re -->|像素 → QImage| qimg qo -->|QPainter 合成| qimg

8.2 逻辑流程

%%{init: {"theme":"neutral","flowchart":{"useMaxWidth":false,"htmlLabels":true}}}%% flowchart TD n0(["分辨率 width, height"]) --> n1["check_resolution()<br/>校验分辨率"] n1 --> n2{"通过?"} n2 -- 否 --> n2a["打印拒绝原因<br/>返回空 QImage"] n2 -- 是 --> n3["MakeCurrent()<br/>借 GL 上下文"] n3 --> n4["保存 Draw / Read FBO 绑定"] n4 --> n5["建离屏窗口<br/>vtkGenericOpenGLRenderWindow<br/>SetOwnContext(0) + NoBlit"] n5 --> n6["克隆 renderer<br/>相机深拷贝 · prop / 光源共享"] n7["ReleaseGraphicsResources(source)<br/>拆掉源窗口侧那套 GL 资源"] n6 --> n7 n7 --> n8["offscreen->Render()<br/>llvmpipe 在 CPU 上光栅化"] n8 --> n9["vtkWindowToImageFilter<br/>回读 RGB 像素"] n9 --> n10["逐行翻转<br/>VTK 左下原点 → QImage 左上原点"] n10 --> n11["ReleaseGraphicsResources(offscreen)<br/>拆掉离屏侧那套 GL 资源"] n11 --> n12["RemoveAllViewProps / RemoveRenderer<br/>Finalize() 离屏窗口"] n12 --> n13["还原 FBO 绑定"] n13 --> n14["Qt compose()<br/>底填色 → 贴画布 → 叠加层"] n14 --> n15(["QImage::save() 写 PNG"])

8.3 离屏导出调用链

%%{init: {"theme":"neutral","sequence":{"useMaxWidth":false,"mirrorActors":false}}}%% sequenceDiagram autonumber participant u as main participant sb as scene_builder participant src as 源窗口 vtkOSOpenGLRenderWindow participant re as render_engine participant offs as 离屏窗口 vtkGenericOpenGLRenderWindow participant grab as vtkWindowToImageFilter participant qo as qt_overlay / QPainter u->>sb: build_scene(src) activate sb sb->>src: AddRenderer ×3 层 sb-->>u: Scene deactivate sb u->>src: Render() 初始化 u->>u: check_resolution(w, h) u->>re: render_offscreen(src, w, h) activate re re->>src: MakeCurrent() 借 GL 上下文 re->>re: 保存 FBO 绑定 re->>offs: New + SetSize + SetOwnContext(0) + NoBlit activate offs re->>src: 深拷相机 + 共享 prop / 光源 re->>src: ReleaseGraphicsResources(source) re->>offs: Render() re->>grab: SetInput + Update activate grab grab-->>re: vtkImageData deactivate grab re->>re: 逐行翻转 → QImage re->>offs: ReleaseGraphicsResources + RemoveAllViewProps + Finalize deactivate offs re->>re: 还原 FBO 绑定 re-->>u: canvas (QImage) deactivate re u->>qo: compose(canvas, overlay, w, h) activate qo qo->>qo: QImage::fill(bgColor) + drawImage + paint_overlay qo-->>u: ExportResult deactivate qo u->>u: QImage::save(PNG)
posted @ 2026-10-02 18:23  起飞的乌龟  阅读(32)  评论(0)    收藏  举报