This commit is contained in:
赵豪 2026-04-24 15:19:09 +08:00
commit 69fb0c21b5
11 changed files with 1057 additions and 0 deletions

58
.gitignore vendored Normal file
View File

@ -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/

View File

@ -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 增加真正的属性编辑,而不是只读展示
- 加入资源浏览器、场景保存和加载
- 继续评估更低开销的纹理共享路径
- 为线程同步、关闭流程、异常恢复补充更系统的测试

5
main.py Normal file
View File

@ -0,0 +1,5 @@
from src.impanda3d import run_app
if __name__ == "__main__":
run_app()

1
src/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Repository source package root."""

View File

@ -0,0 +1,5 @@
"""ImPanda3D editor package."""
from .app import run_app
__all__ = ["run_app"]

11
src/impanda3d/app.py Normal file
View File

@ -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)

11
src/impanda3d/config.py Normal file
View File

@ -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")

241
src/impanda3d/editor.py Normal file
View File

@ -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)

264
src/impanda3d/renderer.py Normal file
View File

@ -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)

149
src/impanda3d/scene.py Normal file
View File

@ -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))

23
src/impanda3d/types.py Normal file
View File

@ -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)