commit 69fb0c21b5152cfefcf568f2cd6676d957f81048 Author: 赵豪 <11985835+Meill2486@user.noreply.gitee.com> Date: Fri Apr 24 15:19:09 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1699cfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class +.python-version +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Build and packaging output +build/ +dist/ +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +# Virtual environments +venv/ +.venv/ +env/ +.env/ +myvenv/ + +# Local environment and configuration +.env* +!.env.example +*.local +*.ini + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS metadata +.DS_Store +Thumbs.db +desktop.ini + +# Logs and runtime output +*.log +logs/ +tmp/ +temp/ +*.tmp + +# Panda3D / render output generated during local runs +model-cache/ +pstats/ +*.pstats +screenshots/ +captures/ +recordings/ + diff --git a/docs/impanda3d_offscreen_imgui_summary.md b/docs/impanda3d_offscreen_imgui_summary.md new file mode 100644 index 0000000..546bf92 --- /dev/null +++ b/docs/impanda3d_offscreen_imgui_summary.md @@ -0,0 +1,289 @@ +# 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 增加真正的属性编辑,而不是只读展示 +- 加入资源浏览器、场景保存和加载 +- 继续评估更低开销的纹理共享路径 +- 为线程同步、关闭流程、异常恢复补充更系统的测试 diff --git a/main.py b/main.py new file mode 100644 index 0000000..c9dad60 --- /dev/null +++ b/main.py @@ -0,0 +1,5 @@ +from src.impanda3d import run_app + + +if __name__ == "__main__": + run_app() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..665a3b2 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Repository source package root.""" diff --git a/src/impanda3d/__init__.py b/src/impanda3d/__init__.py new file mode 100644 index 0000000..e0929eb --- /dev/null +++ b/src/impanda3d/__init__.py @@ -0,0 +1,5 @@ +"""ImPanda3D editor package.""" + +from .app import run_app + +__all__ = ["run_app"] diff --git a/src/impanda3d/app.py b/src/impanda3d/app.py new file mode 100644 index 0000000..8e2acbd --- /dev/null +++ b/src/impanda3d/app.py @@ -0,0 +1,11 @@ +from imgui_bundle import immapp, immvision + +from .editor import EditorApp + + +def run_app() -> None: + immvision.use_rgb_color_order() + + app = EditorApp() + runner_params, addons = app.build_runner_params() + immapp.run(runner_params, addons) diff --git a/src/impanda3d/config.py b/src/impanda3d/config.py new file mode 100644 index 0000000..097d6f8 --- /dev/null +++ b/src/impanda3d/config.py @@ -0,0 +1,11 @@ +from panda3d.core import loadPrcFileData + + +WINDOW_TITLE = "ImPanda3D Editor Prototype" +INI_FILENAME = "ImPanda3D_Editor_Prototype_threaded.ini" + + +def apply_panda_runtime_config() -> None: + loadPrcFileData("", "window-type offscreen") + loadPrcFileData("", "audio-library-name null") + loadPrcFileData("", "sync-video false") diff --git a/src/impanda3d/editor.py b/src/impanda3d/editor.py new file mode 100644 index 0000000..0fb5a38 --- /dev/null +++ b/src/impanda3d/editor.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from imgui_bundle import hello_imgui, imgui, immapp, immvision + +from .config import INI_FILENAME, WINDOW_TITLE +from .renderer import PandaRendererThread + + +class EditorApp: + def __init__(self) -> None: + self.renderer = PandaRendererThread() + self.renderer.start() + + self.selected_node_path = "SceneRoot/PreviewRoot/Teapot" + self.viewport_texture: immvision.GlTexture | None = None + self.viewport_frame_version = -1 + self.viewport_frame_shape: tuple[int, int, int] | None = None + self.render_scale = 1.0 + + def build_runner_params(self) -> tuple[hello_imgui.RunnerParams, immapp.AddOnsParams]: + runner = hello_imgui.RunnerParams() + runner.app_window_params.window_title = WINDOW_TITLE + runner.app_window_params.window_geometry.size = (1600, 960) + runner.app_window_params.restore_previous_geometry = True + runner.ini_filename = INI_FILENAME + + runner.imgui_window_params.menu_app_title = "ImPanda3D" + runner.imgui_window_params.show_menu_bar = True + runner.imgui_window_params.show_status_bar = True + runner.imgui_window_params.default_imgui_window_type = ( + hello_imgui.DefaultImGuiWindowType.provide_full_screen_dock_space + ) + + runner.callbacks.show_status = self._show_status_bar + runner.callbacks.before_exit = self._shutdown + runner.docking_params = self._build_docking_layout() + runner.docking_params.layout_condition = hello_imgui.DockingLayoutCondition.application_start + + addons = immapp.AddOnsParams() + return runner, addons + + def _build_docking_layout(self) -> hello_imgui.DockingParams: + layout = hello_imgui.DockingParams() + + left_split = hello_imgui.DockingSplit() + left_split.initial_dock = "MainDockSpace" + left_split.new_dock = "HierarchySpace" + left_split.direction = imgui.Dir.left + left_split.ratio = 0.18 + + right_split = hello_imgui.DockingSplit() + right_split.initial_dock = "MainDockSpace" + right_split.new_dock = "InspectorSpace" + right_split.direction = imgui.Dir.right + right_split.ratio = 0.24 + + bottom_split = hello_imgui.DockingSplit() + bottom_split.initial_dock = "MainDockSpace" + bottom_split.new_dock = "BottomSpace" + bottom_split.direction = imgui.Dir.down + bottom_split.ratio = 0.23 + + layout.docking_splits = [left_split, right_split, bottom_split] + layout.dockable_windows = [ + hello_imgui.DockableWindow( + label_="Viewport", + dock_space_name_="MainDockSpace", + gui_function_=self.show_viewport, + ), + hello_imgui.DockableWindow( + label_="Hierarchy", + dock_space_name_="HierarchySpace", + gui_function_=self.show_hierarchy, + ), + hello_imgui.DockableWindow( + label_="Inspector", + dock_space_name_="InspectorSpace", + gui_function_=self.show_inspector, + ), + hello_imgui.DockableWindow( + label_="Stats", + dock_space_name_="BottomSpace", + gui_function_=self.show_stats, + ), + ] + return layout + + def _show_status_bar(self) -> None: + ready, size, fps, last_error, _ = self.renderer.snapshot() + imgui.text_unformatted("Render Path: Panda3D worker thread -> simplepbr -> RAM RGBA -> GlTexture") + imgui.same_line() + imgui.separator() + imgui.same_line() + imgui.text(f"Viewport: {size[0]} x {size[1]}") + imgui.same_line() + imgui.separator() + imgui.same_line() + imgui.text(f"Panda FPS: {fps:.1f}") + if not ready: + imgui.same_line() + imgui.separator() + imgui.same_line() + imgui.text_unformatted("warming up") + if last_error: + imgui.same_line() + imgui.separator() + imgui.same_line() + imgui.text_colored((1.0, 0.4, 0.4, 1.0), f"error: {last_error}") + + def _shutdown(self) -> None: + self.renderer.stop() + self.renderer.join(timeout=2.0) + + def _handle_viewport_input(self, hovered: bool) -> None: + if not hovered: + return + + io = imgui.get_io() + if imgui.is_mouse_down(imgui.MouseButton_.right): + self.renderer.orbit(io.mouse_delta.x, io.mouse_delta.y) + elif imgui.is_mouse_down(imgui.MouseButton_.middle): + self.renderer.pan(io.mouse_delta.x, io.mouse_delta.y) + + if io.mouse_wheel != 0.0: + self.renderer.dolly(io.mouse_wheel) + + def show_viewport(self) -> None: + available = imgui.get_content_region_avail() + if available.x < 64 or available.y < 64: + imgui.text_wrapped("Viewport area is too small to render Panda3D.") + return + + width = max(64, int(available.x)) + height = max(64, int(available.y)) + render_width = max(64, int(width * self.render_scale)) + render_height = max(64, int(height * self.render_scale)) + self.renderer.resize(render_width, render_height) + + ready, _, _, last_error, _ = self.renderer.snapshot() + + if ready: + self.viewport_texture, self.viewport_frame_version, _, frame_shape = self.renderer.update_gl_texture_if_needed( + self.viewport_frame_version, + self.viewport_texture, + ) + if frame_shape is not None: + self.viewport_frame_shape = frame_shape + + imgui.invisible_button("viewport_canvas", (width, height)) + rect_min = imgui.get_item_rect_min() + rect_max = imgui.get_item_rect_max() + hovered = imgui.is_item_hovered() + + if self.viewport_texture is not None: + imgui.get_window_draw_list().add_image( + imgui.ImTextureRef(self.viewport_texture.texture_id), + rect_min, + rect_max, + (0.0, 1.0), + (1.0, 0.0), + ) + + self._handle_viewport_input(hovered) + if hovered: + imgui.set_tooltip("RMB orbit\nMMB pan\nWheel dolly") + else: + imgui.text_wrapped("Panda3D renderer is starting. The first frame has not arrived yet.") + + if last_error: + imgui.separator() + imgui.text_colored((1.0, 0.4, 0.4, 1.0), last_error) + + def show_hierarchy(self) -> None: + nodes = self.renderer.scene_snapshot() + if not nodes: + imgui.text_unformatted("Waiting for scene graph...") + return + + for index, node in enumerate(nodes): + self._draw_node_tree(node, f"root-{index}") + + def _draw_node_tree(self, node, node_key: str) -> None: + tree_flags = imgui.TreeNodeFlags_.span_full_width + if not node.children: + tree_flags |= imgui.TreeNodeFlags_.leaf + if node.path == self.selected_node_path: + tree_flags |= imgui.TreeNodeFlags_.selected + + opened = imgui.tree_node_ex(f"{node.name}##{node_key}", tree_flags) + if imgui.is_item_clicked(): + self.selected_node_path = node.path + + if opened: + for index, child in enumerate(node.children): + self._draw_node_tree(child, f"{node_key}-{index}") + imgui.tree_pop() + + def show_inspector(self) -> None: + node = self.renderer.node_snapshot(self.selected_node_path) + if node is None: + imgui.text_unformatted("Select a node from Hierarchy.") + return + + imgui.text(f"Path: {node.path}") + imgui.text(f"Children: {len(node.children)}") + imgui.separator() + imgui.text("Transform") + imgui.bullet_text(f"Position: ({node.pos[0]:.2f}, {node.pos[1]:.2f}, {node.pos[2]:.2f})") + imgui.bullet_text(f"Rotation: ({node.hpr[0]:.2f}, {node.hpr[1]:.2f}, {node.hpr[2]:.2f})") + imgui.bullet_text(f"Scale: ({node.scale[0]:.2f}, {node.scale[1]:.2f}, {node.scale[2]:.2f})") + + camera = self.renderer.camera_state() + imgui.separator() + imgui.text("Camera") + imgui.bullet_text(f"Target: ({camera.target_x:.2f}, {camera.target_y:.2f}, {camera.target_z:.2f})") + imgui.bullet_text(f"Distance: {camera.distance:.2f}") + imgui.bullet_text(f"Heading: {camera.heading_deg:.2f}") + imgui.bullet_text(f"Pitch: {camera.pitch_deg:.2f}") + + def show_stats(self) -> None: + ready, size, fps, last_error, _ = self.renderer.snapshot() + frame_shape = self.viewport_frame_shape + imgui.text(f"Renderer ready: {ready}") + if frame_shape is not None: + imgui.text(f"Frame shape: {frame_shape[1]} x {frame_shape[0]} x {frame_shape[2]}") + else: + imgui.text("Frame shape: n/a") + imgui.text(f"Requested size: {size[0]} x {size[1]}") + imgui.text(f"Panda worker FPS: {fps:.1f}") + _, self.render_scale = imgui.slider_float("Render Scale", self.render_scale, 0.4, 1.0, "%.2f") + imgui.separator() + imgui.text_wrapped( + "The original black screen came from rendering Panda3D inside the same " + "OpenGL GUI frame used by Hello ImGui. This version moves Panda3D rendering " + "to a worker thread, runs simplepbr on the offscreen buffer, uploads only " + "the latest RGBA frame to a GlTexture, and renders the viewport with a raw " + "ImGui draw list instead of ImmVision." + ) + if last_error: + imgui.separator() + imgui.text_colored((1.0, 0.4, 0.4, 1.0), last_error) diff --git a/src/impanda3d/renderer.py b/src/impanda3d/renderer.py new file mode 100644 index 0000000..2f05804 --- /dev/null +++ b/src/impanda3d/renderer.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import math +import threading +import time + +import direct.task.Task as PandaTaskModule +import numpy as np +import simplepbr +from direct.showbase.ShowBase import ShowBase +from imgui_bundle import immvision +from panda3d.core import ( + FrameBufferProperties, + GraphicsOutput, + GraphicsPipe, + LVecBase4f, + Texture, + WindowProperties, +) + +from .config import apply_panda_runtime_config +from .scene import apply_camera_state, build_preview_scene +from .types import CameraState, SceneNodeSnapshot + + +class PandaRendererThread(threading.Thread): + def __init__(self, width: int = 1280, height: int = 720) -> None: + super().__init__(daemon=True) + self._stop_event = threading.Event() + self._state_lock = threading.Lock() + self._frame_lock = threading.Lock() + + self._requested_size = (width, height) + self._camera_state = CameraState() + self._latest_frame = np.zeros((height, width, 4), dtype=np.uint8) + self._spare_frame = np.zeros((height, width, 4), dtype=np.uint8) + self._frame_ready = False + self._frame_version = 0 + self._scene_snapshot: list[SceneNodeSnapshot] = [] + self._node_lookup: dict[str, SceneNodeSnapshot] = {} + self._last_error: str | None = None + self._fps: float = 0.0 + self._target_frame_time = 1.0 / 120.0 + + def stop(self) -> None: + self._stop_event.set() + + def snapshot(self) -> tuple[bool, tuple[int, int], float, str | None, int]: + with self._frame_lock: + ready = self._frame_ready + size = self._requested_size + fps = self._fps + last_error = self._last_error + frame_version = self._frame_version + return ready, size, fps, last_error, frame_version + + def update_gl_texture_if_needed( + self, + last_version: int, + texture: immvision.GlTexture | None, + ) -> tuple[immvision.GlTexture | None, int, bool, tuple[int, int, int] | None]: + with self._frame_lock: + if not self._frame_ready or self._frame_version == last_version: + return texture, last_version, False, None + + frame = self._latest_frame + if texture is None: + texture = immvision.GlTexture(frame) + else: + texture.update_from_image(frame) + + return texture, self._frame_version, True, frame.shape + + def scene_snapshot(self) -> list[SceneNodeSnapshot]: + with self._state_lock: + return self._scene_snapshot + + def node_snapshot(self, path: str) -> SceneNodeSnapshot | None: + with self._state_lock: + return self._node_lookup.get(path) + + def resize(self, width: int, height: int) -> None: + with self._state_lock: + self._requested_size = (max(64, int(width)), max(64, int(height))) + + def orbit(self, delta_x: float, delta_y: float) -> None: + with self._state_lock: + self._camera_state.heading_deg -= delta_x * 0.25 + self._camera_state.pitch_deg = max( + -85.0, + min(85.0, self._camera_state.pitch_deg - delta_y * 0.20), + ) + + def pan(self, delta_x: float, delta_y: float) -> None: + with self._state_lock: + heading_rad = math.radians(self._camera_state.heading_deg) + distance_scale = max(self._camera_state.distance * 0.01, 0.01) + self._camera_state.target_x -= math.cos(heading_rad) * delta_x * distance_scale + self._camera_state.target_y -= math.sin(heading_rad) * delta_x * distance_scale + self._camera_state.target_z += delta_y * distance_scale + + def dolly(self, wheel_delta: float) -> None: + with self._state_lock: + zoom_factor = math.pow(0.9, wheel_delta) + self._camera_state.distance = max( + 3.0, + min(200.0, self._camera_state.distance * zoom_factor), + ) + + def camera_state(self) -> CameraState: + with self._state_lock: + camera = self._camera_state + return CameraState( + target_x=camera.target_x, + target_y=camera.target_y, + target_z=camera.target_z, + distance=camera.distance, + heading_deg=camera.heading_deg, + pitch_deg=camera.pitch_deg, + ) + + def run(self) -> None: + apply_panda_runtime_config() + + base: ShowBase | None = None + previous_task_signal = PandaTaskModule.signal + try: + base = ShowBase(windowType="offscreen") + texture = Texture() + texture.set_keep_ram_image(True) + texture.set_minfilter(Texture.FTLinear) + texture.set_magfilter(Texture.FTLinear) + texture.set_format(Texture.FRgba8) + + buffer_props = FrameBufferProperties() + buffer_props.set_rgb_color(True) + buffer_props.set_rgba_bits(8, 8, 8, 8) + buffer_props.set_depth_bits(24) + + window_props = WindowProperties() + window_props.set_size(*self._requested_size) + + buffer = base.graphicsEngine.make_output( + base.pipe, + "ImGuiViewportBuffer", + -100, + buffer_props, + window_props, + GraphicsPipe.BF_refuse_window | GraphicsPipe.BF_resizeable, + base.win.get_gsg(), + base.win, + ) + if buffer is None: + raise RuntimeError("Failed to create Panda3D offscreen buffer.") + + buffer.addRenderTexture(texture, GraphicsOutput.RTMCopyRam) + buffer.set_clear_color(LVecBase4f(0.08, 0.09, 0.11, 1.0)) + + camera_np = base.makeCamera(buffer) + lens = camera_np.node().get_lens() + lens.set_near_far(0.1, 5000.0) + + simplepbr.init( + render_node=base.render, + window=buffer, + camera_node=camera_np, + taskmgr=base.taskMgr, + use_normal_maps=True, + use_emission_maps=True, + use_occlusion_maps=True, + enable_shadows=False, + exposure=0.35, + ) + + scene_root = build_preview_scene(base) + self._update_scene_snapshot(scene_root) + + last_time = time.perf_counter() + smoothed_dt = 1.0 / 60.0 + last_snapshot_update = 0.0 + + # Panda's TaskManager installs a SIGINT handler inside step(), which is illegal + # from a non-main thread. Disable that handler path for this worker loop. + PandaTaskModule.signal = None + + while not self._stop_event.is_set(): + frame_start = time.perf_counter() + with self._state_lock: + width, height = self._requested_size + camera = self._camera_state + + buffer.set_size(width, height) + lens.set_aspect_ratio(width / height) + apply_camera_state(camera_np, camera) + + now = time.perf_counter() + dt = now - last_time + last_time = now + smoothed_dt = smoothed_dt * 0.9 + dt * 0.1 + + base.taskMgr.step() + + if texture.has_ram_image() and texture.get_x_size() > 0 and texture.get_y_size() > 0: + rgba = texture.get_ram_image_as("RGBA") + frame_view = np.frombuffer(rgba, dtype=np.uint8).reshape( + (texture.get_y_size(), texture.get_x_size(), 4) + ) + with self._frame_lock: + if self._latest_frame.shape != frame_view.shape: + self._latest_frame = np.empty_like(frame_view) + self._spare_frame = np.empty_like(frame_view) + np.copyto(self._spare_frame, frame_view) + self._latest_frame, self._spare_frame = self._spare_frame, self._latest_frame + self._frame_ready = True + self._frame_version += 1 + self._fps = 0.0 if smoothed_dt <= 0.0 else (1.0 / smoothed_dt) + self._last_error = None + + if now - last_snapshot_update >= 0.25: + self._update_scene_snapshot(scene_root) + last_snapshot_update = now + + frame_elapsed = time.perf_counter() - frame_start + if frame_elapsed < self._target_frame_time: + time.sleep(self._target_frame_time - frame_elapsed) + except Exception as exc: + with self._frame_lock: + self._last_error = repr(exc) + finally: + PandaTaskModule.signal = previous_task_signal + if base is not None: + try: + base.destroy() + except Exception: + pass + + def _update_scene_snapshot(self, scene_root) -> None: + children = [self._snapshot_node(child, scene_root.get_name()) for child in scene_root.get_children()] + node_lookup: dict[str, SceneNodeSnapshot] = {} + for child in children: + self._flatten_snapshot(child, node_lookup) + with self._state_lock: + self._scene_snapshot = children + self._node_lookup = node_lookup + + def _snapshot_node(self, node, parent_path: str) -> SceneNodeSnapshot: + pos = node.get_pos() + hpr = node.get_hpr() + scale = node.get_scale() + path = f"{parent_path}/{node.get_name()}" + snapshot = SceneNodeSnapshot( + path=path, + name=node.get_name(), + pos=(pos.x, pos.y, pos.z), + hpr=(hpr.x, hpr.y, hpr.z), + scale=(scale.x, scale.y, scale.z), + ) + snapshot.children = [self._snapshot_node(child, path) for child in node.get_children()] + return snapshot + + def _flatten_snapshot(self, node: SceneNodeSnapshot, lookup: dict[str, SceneNodeSnapshot]) -> None: + lookup[node.path] = node + for child in node.children: + self._flatten_snapshot(child, lookup) diff --git a/src/impanda3d/scene.py b/src/impanda3d/scene.py new file mode 100644 index 0000000..c9eca83 --- /dev/null +++ b/src/impanda3d/scene.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import math + +from direct.showbase.ShowBase import ShowBase +from panda3d.core import ( + AmbientLight, + DirectionalLight, + Material, + PointLight, + Vec3, +) + +from .types import CameraState + + +def build_preview_scene(base: ShowBase): + base.render.set_shader_auto() + base.setBackgroundColor(0.16, 0.17, 0.19, 1.0) + base.disableMouse() + + ambient = AmbientLight("ambient") + ambient.set_color((0.10, 0.10, 0.11, 1.0)) + base.render.set_light(base.render.attachNewNode(ambient)) + + key_light = DirectionalLight("keyLight") + key_light.set_color((1.45, 1.40, 1.30, 1.0)) + key_np = base.render.attachNewNode(key_light) + key_np.set_hpr(-38.0, -34.0, 0.0) + base.render.set_light(key_np) + + fill_light = DirectionalLight("fillLight") + fill_light.set_color((0.38, 0.44, 0.52, 1.0)) + fill_np = base.render.attachNewNode(fill_light) + fill_np.set_hpr(118.0, -18.0, 0.0) + base.render.set_light(fill_np) + + rim_light = DirectionalLight("rimLight") + rim_light.set_color((0.30, 0.28, 0.26, 1.0)) + rim_np = base.render.attachNewNode(rim_light) + rim_np.set_hpr(180.0, -8.0, 0.0) + base.render.set_light(rim_np) + + bounce_light = PointLight("bounceLight") + bounce_light.set_color((0.22, 0.24, 0.28, 1.0)) + bounce_np = base.render.attachNewNode(bounce_light) + bounce_np.set_pos(0.0, -1.5, 4.0) + base.render.set_light(bounce_np) + + scene_root = base.render.attachNewNode("SceneRoot") + preview_root = scene_root.attachNewNode("PreviewRoot") + + floor = base.loader.loadModel("models/box") + floor.set_name("Floor") + floor.reparentTo(scene_root) + floor.set_scale(9.5, 9.5, 0.08) + floor.set_pos(0.0, 0.0, -0.08) + floor_material = Material("FloorMaterial") + floor_material.set_base_color((0.43, 0.45, 0.48, 1.0)) + floor_material.set_roughness(0.94) + floor_material.set_metallic(0.0) + floor.set_material(floor_material, 1) + + backdrop = base.loader.loadModel("models/box") + backdrop.set_name("Backdrop") + backdrop.reparentTo(scene_root) + backdrop.set_scale(9.5, 0.12, 6.0) + backdrop.set_pos(0.0, 5.8, 3.8) + backdrop_material = Material("BackdropMaterial") + backdrop_material.set_base_color((0.24, 0.25, 0.28, 1.0)) + backdrop_material.set_roughness(1.0) + backdrop_material.set_metallic(0.0) + backdrop.set_material(backdrop_material, 1) + + pedestal_material = Material("PedestalMaterial") + pedestal_material.set_base_color((0.55, 0.57, 0.60, 1.0)) + pedestal_material.set_roughness(0.82) + pedestal_material.set_metallic(0.0) + + left_pedestal = base.loader.loadModel("models/box") + left_pedestal.set_name("LeftPedestal") + left_pedestal.reparentTo(scene_root) + left_pedestal.set_scale(1.15, 1.15, 0.55) + left_pedestal.set_pos(-3.4, 0.1, 0.55) + left_pedestal.set_material(pedestal_material, 1) + + right_pedestal = base.loader.loadModel("models/box") + right_pedestal.set_name("RightPedestal") + right_pedestal.reparentTo(scene_root) + right_pedestal.set_scale(1.15, 1.15, 0.55) + right_pedestal.set_pos(3.4, 0.1, 0.55) + right_pedestal.set_material(pedestal_material, 1) + + center_pedestal = base.loader.loadModel("models/box") + center_pedestal.set_name("CenterPedestal") + center_pedestal.reparentTo(scene_root) + center_pedestal.set_scale(1.55, 1.55, 0.34) + center_pedestal.set_pos(0.0, 0.1, 0.34) + center_pedestal.set_material(pedestal_material, 1) + + smiley = base.loader.loadModel("models/smiley") + smiley.set_name("Smiley") + smiley.reparentTo(preview_root) + smiley.set_scale(1.55) + smiley.set_pos(-3.4, 0.1, 2.15) + smiley_material = Material("SmileyMaterial") + smiley_material.set_base_color((1.0, 0.95, 0.92, 1.0)) + smiley_material.set_roughness(0.24) + smiley_material.set_metallic(0.02) + smiley.set_material(smiley_material, 1) + + teapot = base.loader.loadModel("models/teapot") + teapot.set_name("Teapot") + teapot.reparentTo(preview_root) + teapot.set_scale(1.15) + teapot.set_hpr(25.0, 0.0, 0.0) + teapot.set_pos(0.0, 0.0, 1.12) + teapot_material = Material("TeapotMaterial") + teapot_material.set_base_color((0.80, 0.36, 0.24, 1.0)) + teapot_material.set_roughness(0.42) + teapot_material.set_metallic(0.05) + teapot.set_material(teapot_material, 1) + + cube = base.loader.loadModel("models/misc/rgbCube") + cube.set_name("PreviewCube") + cube.reparentTo(preview_root) + cube.set_scale(1.18) + cube.set_hpr(-28.0, 18.0, -12.0) + cube.set_pos(3.4, 0.1, 1.85) + cube_material = Material("PreviewCubeMaterial") + cube_material.set_base_color((0.72, 0.74, 0.78, 1.0)) + cube_material.set_roughness(0.28) + cube_material.set_metallic(0.72) + cube.set_material(cube_material, 1) + + return scene_root + + +def apply_camera_state(camera_np, camera: CameraState) -> None: + pitch_rad = math.radians(camera.pitch_deg) + heading_rad = math.radians(camera.heading_deg) + distance = max(camera.distance, 1.0) + + x = camera.target_x + distance * math.cos(pitch_rad) * math.sin(heading_rad) + y = camera.target_y - distance * math.cos(pitch_rad) * math.cos(heading_rad) + z = camera.target_z + distance * math.sin(pitch_rad) + + camera_np.set_pos(x, y, z) + camera_np.look_at(Vec3(camera.target_x, camera.target_y, camera.target_z)) diff --git a/src/impanda3d/types.py b/src/impanda3d/types.py new file mode 100644 index 0000000..45f06a9 --- /dev/null +++ b/src/impanda3d/types.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class CameraState: + target_x: float = 0.0 + target_y: float = 0.0 + target_z: float = 1.4 + distance: float = 14.0 + heading_deg: float = 32.0 + pitch_deg: float = -14.0 + + +@dataclass +class SceneNodeSnapshot: + path: str + name: str + pos: tuple[float, float, float] + hpr: tuple[float, float, float] + scale: tuple[float, float, float] + children: list["SceneNodeSnapshot"] = field(default_factory=list)