10 KiB
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 offscreensrc/impanda3d/types.py放编辑器共享数据结构,例如CameraState、SceneNodeSnapshotsrc/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 布局
- 构建
Viewport、Hierarchy、Inspector、Stats - 用状态栏显示当前渲染路径和帧率
这个阶段重点不是视觉效果,而是先确定编辑器基础 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()绑定到离屏buffercamera_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 增加真正的属性编辑,而不是只读展示
- 加入资源浏览器、场景保存和加载
- 继续评估更低开销的纹理共享路径
- 为线程同步、关闭流程、异常恢复补充更系统的测试