init
This commit is contained in:
commit
69fb0c21b5
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal 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/
|
||||||
|
|
||||||
289
docs/impanda3d_offscreen_imgui_summary.md
Normal file
289
docs/impanda3d_offscreen_imgui_summary.md
Normal 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
5
main.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from src.impanda3d import run_app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_app()
|
||||||
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Repository source package root."""
|
||||||
5
src/impanda3d/__init__.py
Normal file
5
src/impanda3d/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""ImPanda3D editor package."""
|
||||||
|
|
||||||
|
from .app import run_app
|
||||||
|
|
||||||
|
__all__ = ["run_app"]
|
||||||
11
src/impanda3d/app.py
Normal file
11
src/impanda3d/app.py
Normal 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
11
src/impanda3d/config.py
Normal 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
241
src/impanda3d/editor.py
Normal 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
264
src/impanda3d/renderer.py
Normal 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
149
src/impanda3d/scene.py
Normal 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
23
src/impanda3d/types.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user