RobotMetaCore/docs/impanda3d_offscreen_imgui_summary.md
2026-04-24 15:19:09 +08:00

10 KiB
Raw Blame History

ImPanda3D 离屏渲染编辑器实现总结

目标

这个原型的目标是把 Panda3D 作为场景渲染层,把 ImGui 作为编辑器 GUI 层,形成一个类似编辑器的基础架构:

  • Panda3D 负责场景、相机、灯光、材质和离屏渲染
  • Hello ImGui / imgui-bundle 负责 Docking、Viewport、Hierarchy、Inspector、Stats 等编辑器面板
  • Panda3D 的离屏结果最终显示到 ImGui 的 Viewport 中

当前实现不是把 Panda3D 直接嵌进 ImGui 的同一个实时 OpenGL 渲染流程里,而是采用更稳妥的路径:

Panda3D worker thread -> offscreen buffer -> RAM RGBA -> GlTexture -> ImGui viewport

当前模块拆分

主入口已经收口到 main.py,核心逻辑拆分到 src/impanda3d/

  • src/impanda3d/config.py 负责 Panda3D 运行时配置,例如 window-type offscreen
  • src/impanda3d/types.py 放编辑器共享数据结构,例如 CameraStateSceneNodeSnapshot
  • src/impanda3d/scene.py 负责预览场景搭建、灯光、材质、相机状态应用
  • src/impanda3d/renderer.py 负责 Panda3D 离屏渲染线程、帧同步、场景快照、simplepbr 初始化
  • src/impanda3d/editor.py 负责 ImGui 编辑器布局、Viewport 绘制、Hierarchy、Inspector、Stats、输入交互
  • src/impanda3d/app.py 负责组装 EditorApp 并启动 immapp.run()

这样拆分后的好处是:

  • Panda3D 渲染逻辑和 ImGui 编辑器逻辑解耦
  • 场景搭建和渲染线程逻辑不再混在一个文件里
  • 后续继续加 Gizmo、场景编辑、资源浏览器时更容易扩展

实现流程

1. 先验证 Panda3D 可以离屏渲染

最初的方向参考了 example/QPanda3D 的思路,本质上是先证明 Panda3D 内容可以不直接显示在自己的窗口里,而是输出到一个中间图像目标,再由别的 GUI 系统承接。

这一步确认了一个关键事实Panda3D 的场景内容可以稳定离屏输出,因此它可以作为编辑器内部的渲染子系统存在。

2. 用 Hello ImGui 搭出编辑器壳子

接着把编辑器界面切换为 imgui-bundle / Hello ImGui

  • 使用 Docking 布局
  • 构建 ViewportHierarchyInspectorStats
  • 用状态栏显示当前渲染路径和帧率

这个阶段重点不是视觉效果,而是先确定编辑器基础 UI 壳子已经成立。

3. Panda3D 输出到离屏 Buffer

在 Panda3D 侧使用 ShowBase(windowType="offscreen"),然后手动创建离屏 buffer

  • 配置 FrameBufferProperties
  • 创建 GraphicsOutput
  • 把颜色结果输出到 Texture
  • 使用 RTMCopyRam 拿到 RGBA 数据

这样就有了一个稳定的离屏渲染目标,后面 ImGui 只需要消费这个结果。

4. Panda3D 放到独立 worker thread

最早的问题是:如果把 Panda3D 渲染和 Hello ImGui 的 GUI 渲染强行放在同一个 OpenGL 生命周期里,容易出现黑屏或初始化异常。

因此当前方案改成:

  • 主线程只负责 ImGui
  • Panda3D 在独立线程里跑离屏渲染
  • 线程之间只交换最新一帧的 RGBA 数据和场景快照

这一步是整个原型最关键的架构调整。它牺牲了一部分纯粹的理论性能上限,但换来了明显更高的稳定性和更低的集成复杂度。

5. Viewport 从 immvision.image() 改为原始纹理绘制

中间版本里Viewport 是通过 immvision.image() 直接显示 numpy 图像。这个方案可以工作,但暴露了两个问题:

  • 图像区域不容易精确铺满整个 viewport 内容区
  • UI 侧封装层更厚,控制空间和性能都不够理想

后面改成:

  • Panda3D worker 继续产出 RGBA numpy 数据
  • UI 侧把最新帧更新到 immvision.GlTexture
  • 用 ImGui 原始 draw list 的 add_image() 直接把纹理画满当前 viewport 矩形

这个改动之后viewport 铺满问题得到解决,帧率也比之前更高。

6. 集成 panda3d-simplepbr

在离屏 buffer 方案稳定后,把 panda3d-simplepbr 接入到 Panda3D worker 内部:

  • simplepbr.init() 绑定到离屏 buffer
  • camera_node 指向离屏相机
  • taskmgr 使用 Panda3D 自己的 base.taskMgr

这让离屏预览不再只是基础固定管线效果,而是具备更接近现代预览窗口的材质表现。

7. 调整灯光与场景,让画面更像编辑器预览

在 simplepbr 加入后,又继续把场景从 Panda3D demo 风格调整为更像编辑器预览:

  • 背景改为中性深灰
  • 使用环境光、主光、补光、轮廓光、反弹光
  • 放置地面、背景板、展示台
  • 给预览模型设置更中性的材质参数

目标不是做一张“漂亮的 demo 图”,而是做一个更适合观察模型、材质和轮廓的编辑器预览环境。

遇到的问题与解决方法

1. 同线程 OpenGL 集成导致黑屏

问题

把 Panda3D 渲染塞进 Hello ImGui 同一个 GUI 渲染流程里时,窗口可能是黑屏,或者后续没有正常渲染。

原因

Panda3D 和 Hello ImGui 都会对 OpenGL 上下文和渲染生命周期进行管理。Python 这一层再叠加后,两个系统直接共用一个渲染时序非常脆弱。

解决

放弃“同线程同上下文直连”的做法,改成 Panda3D worker thread 离屏渲染UI 线程只做纹理上传与显示。

2. 探索过原生纹理直连,但当前环境不可行

问题

理论上最理想的路径是零拷贝共享 GPU 纹理,也就是 Panda3D 渲染出一张纹理ImGui 直接拿这个原生 texture id 显示。

实际结果

做过验证后发现,这条路径在当前 Python + Panda3D + Hello ImGui 组合下不稳定:

  • Panda3D 的 native texture id 的确能拿到
  • 但把 Panda3D 初始化塞进 Hello ImGui 主线程 GL 生命周期时,会破坏 ImGui backend 的设备对象初始化
  • 实际表现包括 ImGui_ImplOpenGL3_CreateDeviceObjects() failed

结论

当前工程里不采用共享原生纹理的做法,优先使用稳定的离屏 RGBA 回传路径。

3. simplepbr 接入后worker 线程需要 taskMgr.step()

问题

simplepbr 并不是纯静态初始化,它会往 Panda3D 的 task manager 注册任务。因此如果只手动调用底层 graphicsEngine.render_frame(),效果链不会完整更新。

解决

在 worker 线程中使用:

base.taskMgr.step()

这样 simplepbr 相关任务也会一起推进,离屏画面才能正常带上 PBR 效果。

4. taskMgr.step() 在子线程里触发 signal only works in main thread

问题

一旦在 worker thread 调用 base.taskMgr.step(),就会遇到类似异常:

ValueError('signal only works in main thread of the main interpreter')

原因

Panda3D 的 TaskManager 在 step() 过程中会尝试处理 SIGINT 相关逻辑,而 Python 的 signal 机制只允许主线程这么做。

解决

在 worker 循环内临时禁用 direct.task.Task.signal 这一分支,结束时再恢复原值:

  • 进入 worker 主循环前保存旧值
  • 运行时设置 PandaTaskModule.signal = None
  • finally 中恢复

这是当前架构下让 Panda3D task 系统在子线程可用的关键兼容处理。

5. Viewport 不能完全铺满

问题

早期使用 immvision.image() 时,图像在 viewport 里没有完全铺满,存在明显空白边界。

原因

immvision.image() 更偏向通用图像查看组件,不是专门为“编辑器主视口完全填充绘制”设计的。它会带来自己的布局、显示参数和边距语义。

解决

改用:

  • imgui.invisible_button() 占满可用区域
  • 读取该区域的 rect_min / rect_max
  • draw_list.add_image() 直接绘制

这样可以严格按 viewport 内容区绘制,结果更像真正的编辑器视图。

6. 帧率不高

问题

最初版本里帧率偏低,特别是在 viewport 放大时更明显。

主要瓶颈

  • Panda3D 离屏渲染本身有成本
  • RTMCopyRam 会发生 GPU 到 CPU 的读回
  • numpy 帧数据更新会有额外内存带宽消耗
  • UI 层如果使用更高层封装控件,也会增加额外开销

已做优化

  • Viewport 改为 GlTexture + draw_list.add_image()
  • 减少不必要的图像路径封装
  • 使用双缓冲思路保存最新帧
  • 场景树快照不再每帧更新,而是按周期更新
  • 增加 Render Scale 滑杆,让用户可以主动降低离屏渲染分辨率
  • UV 翻转放到 ImGui 采样坐标层面处理,避免额外整帧翻转拷贝

性能结论

就当前架构而言,性能属于“可用的编辑器原型级别”,但不属于最优路径。

它的最大瓶颈仍然是:

Panda3D offscreen -> GPU readback -> CPU numpy -> OpenGL upload

也就是说,它本质上还是一条“读回再上传”的链路,不是共享 GPU 纹理链路。

当前架构的性能判断

从工程稳定性和落地难度看,当前架构是合理的:

  • 优点是稳定、可控、容易调试、便于继续扩展编辑器逻辑
  • 缺点是存在 CPU 读回和二次上传,性能上限受限

如果只是做编辑器原型、工具界面、节点树、Inspector、简单 Gizmo 和中等规模场景预览,这条路径是可以继续推进的。

如果以后要做更高帧率、更大分辨率、更多后处理、更多编辑器叠加层,那么后续需要继续评估:

  • 是否能做稳定的共享 GPU 纹理
  • 是否能改为更低开销的图像桥接方式
  • 是否需要把 Panda3D 视口彻底作为独立原生窗口嵌入

当前代码状态

目前代码已经完成这些能力:

  • Panda3D 在独立线程离屏渲染
  • ImGui 编辑器 Docking 布局可用
  • Viewport 可完整铺满内容区
  • 支持鼠标 orbit、pan、dolly
  • 支持 simplepbr 离屏预览
  • 预览场景更接近编辑器环境而非 demo 场景
  • 支持基础场景层级查看和 Inspector 信息展示
  • 支持渲染缩放以平衡分辨率与帧率

下一步建议

  • 把场景数据和渲染线程进一步抽象成编辑器服务层
  • 引入 Gizmo 与对象选中反馈
  • 给 Inspector 增加真正的属性编辑,而不是只读展示
  • 加入资源浏览器、场景保存和加载
  • 继续评估更低开销的纹理共享路径
  • 为线程同步、关闭流程、异常恢复补充更系统的测试