# 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` 放编辑器共享数据结构,例如 `CameraState`、`SceneNodeSnapshot` - `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 布局 - 构建 `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()` 绑定到离屏 `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 增加真正的属性编辑,而不是只读展示 - 加入资源浏览器、场景保存和加载 - 继续评估更低开销的纹理共享路径 - 为线程同步、关闭流程、异常恢复补充更系统的测试