保存、打开项目优化

This commit is contained in:
ayuan9957 2026-03-24 20:51:39 +08:00
parent 062bd4e720
commit 5c0a61d253
11 changed files with 679 additions and 128 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

View File

@ -1,9 +0,0 @@
{
"name": "smoke_project",
"path": "D:\\IMGUI\\EG\\eg_smoke_x76dg4m2\\smoke_project",
"created_at": "2026-03-15 17:36:38",
"last_modified": "2026-03-15 17:36:39",
"version": "1.0.0",
"engine_version": "1.0.0",
"scene_file": "scenes\\scene.bam"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

View File

@ -31,19 +31,19 @@ DockId=0x0000000D,0
[Window][场景树] [Window][场景树]
Pos=0,20 Pos=0,20
Size=339,1084 Size=339,1008
Collapsed=0 Collapsed=0
DockId=0x00000007,0 DockId=0x00000007,0
[Window][属性面板] [Window][属性面板]
Pos=1694,20 Pos=1506,20
Size=346,1084 Size=346,1008
Collapsed=0 Collapsed=0
DockId=0x00000002,0 DockId=0x00000002,0
[Window][控制台] [Window][控制台]
Pos=341,705 Pos=341,629
Size=1351,399 Size=1163,399
Collapsed=0 Collapsed=0
DockId=0x00000006,1 DockId=0x00000006,1
@ -59,7 +59,7 @@ Collapsed=0
[Window][WindowOverViewport_11111111] [Window][WindowOverViewport_11111111]
Pos=0,20 Pos=0,20
Size=2040,1084 Size=1852,1008
Collapsed=0 Collapsed=0
[Window][测试窗口1] [Window][测试窗口1]
@ -78,17 +78,17 @@ Size=93,65
Collapsed=0 Collapsed=0
[Window][新建项目] [Window][新建项目]
Pos=1080,525 Pos=824,402
Size=400,300 Size=400,300
Collapsed=0 Collapsed=0
[Window][选择路径] [Window][选择路径]
Pos=720,302 Pos=626,264
Size=600,500 Size=600,500
Collapsed=0 Collapsed=0
[Window][打开项目] [Window][打开项目]
Pos=770,352 Pos=676,314
Size=500,400 Size=500,400
Collapsed=0 Collapsed=0
@ -98,8 +98,8 @@ Size=600,500
Collapsed=0 Collapsed=0
[Window][资源管理器] [Window][资源管理器]
Pos=341,705 Pos=341,629
Size=1351,399 Size=1163,399
Collapsed=0 Collapsed=0
DockId=0x00000006,0 DockId=0x00000006,0
@ -226,7 +226,7 @@ Size=460,260
Collapsed=0 Collapsed=0
[Docking][Data] [Docking][Data]
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2040,1084 Split=X DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1852,1008 Split=X
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=2212,1012 Split=X DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=2212,1012 Split=X
DockNode ID=0x00000007 Parent=0x00000001 SizeRef=339,1084 Selected=0xE0015051 DockNode ID=0x00000007 Parent=0x00000001 SizeRef=339,1084 Selected=0xE0015051
DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1871,1084 Split=Y DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1871,1084 Split=Y

View File

@ -47,6 +47,102 @@ class ProjectManager:
def _get_repo_root(self): def _get_repo_root(self):
return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _vector_differs(self, current_value, target_values, tolerance=1e-4):
try:
current_list = [float(current_value[i]) for i in range(3)]
except Exception:
try:
current_list = [float(current_value.x), float(current_value.y), float(current_value.z)]
except Exception:
current_list = []
if len(current_list) < 3:
return True
try:
target_list = [float(target_values[0]), float(target_values[1]), float(target_values[2])]
except Exception:
return True
return any(abs(current_list[i] - target_list[i]) > tolerance for i in range(3))
def _log_render_runtime_stats(self, label):
"""Print renderer/task stats to compare manual import vs project-open paths."""
world = getattr(self, "world", None)
if not world:
return
try:
win = getattr(world, "win", None)
num_regions = win.getNumDisplayRegions() if win else 0
region_lines = []
if win:
for index in range(num_regions):
try:
dr = win.getDisplayRegion(index)
camera = dr.getCamera()
camera_name = "None"
if camera and not camera.isEmpty():
camera_name = camera.getName()
region_lines.append(
f"{index}:{dr.getSort()}:{int(bool(dr.isActive()))}:{camera_name}"
)
except Exception:
continue
graphics_engine = getattr(world, "graphicsEngine", None)
num_windows = graphics_engine.getNumWindows() if graphics_engine else 0
render = getattr(world, "render", None)
camera_count = 0
special_camera_counts = {}
if render and not render.isEmpty():
for pattern in ("**/+Camera",):
try:
camera_count += render.find_all_matches(pattern).get_num_paths()
except Exception:
pass
for camera_name in (
"gizmo_overlay_cam",
"pick_camera",
"selection_outline_mask_camera",
):
try:
special_camera_counts[camera_name] = render.find_all_matches(
f"**/{camera_name}"
).get_num_paths()
except Exception:
special_camera_counts[camera_name] = 0
task_mgr = getattr(world, "taskMgr", None) or getattr(world, "task_mgr", None)
task_names = []
if task_mgr:
try:
for task in list(task_mgr.getTasks()):
try:
task_names.append(task.name)
except Exception:
continue
except Exception:
task_names = []
interesting_tasks = [
name for name in task_names
if (
"gizmo" in str(name).lower()
or "outline" in str(name).lower()
or "pick" in str(name).lower()
or "lui" in str(name).lower()
or "canvas" in str(name).lower()
)
]
print(
f"[RenderStats:{label}] regions={num_regions} windows={num_windows} "
f"render_cameras={camera_count} special={special_camera_counts} "
f"interesting_tasks={interesting_tasks}"
)
if region_lines:
print(f"[RenderStats:{label}] region_detail={' | '.join(region_lines)}")
except Exception:
pass
def get_project_scripts_dir(self, project_path=None): def get_project_scripts_dir(self, project_path=None):
project_path = os.path.normpath(project_path or self.current_project_path or "") project_path = os.path.normpath(project_path or self.current_project_path or "")
if not project_path: if not project_path:
@ -689,6 +785,10 @@ class ProjectManager:
""" """
# print(f"\n[DEBUG] ===== 开始打开项目: {project_path} =====") # print(f"\n[DEBUG] ===== 开始打开项目: {project_path} =====")
try: try:
try:
self.world._scene_tree_epoch = int(getattr(self.world, "_scene_tree_epoch", 0) or 0) + 1
except Exception:
self.world._scene_tree_epoch = 1
if not project_path: if not project_path:
print("错误: 项目路径不能为空") print("错误: 项目路径不能为空")
return False return False
@ -913,6 +1013,14 @@ class ProjectManager:
scene_file_path = os.path.join(project_path, scene_entry["path"].replace("/", os.sep)) scene_file_path = os.path.join(project_path, scene_entry["path"].replace("/", os.sep))
save_json(scene_file_path, scene_description) save_json(scene_file_path, scene_description)
self._write_scene_sidecars(scene_paths, gui_snapshot, lui_snapshot) self._write_scene_sidecars(scene_paths, gui_snapshot, lui_snapshot)
try:
print(
f"[SceneSave] roots={len(root_nodes)} nodes={len(scene_description.get('nodes', []) or [])} "
f"assets={len(scene_description.get('referenced_asset_guids', []) or [])} "
f"scripts={len(scene_description.get('referenced_script_guids', []) or [])}"
)
except Exception:
pass
return scene_description return scene_description
def _load_scene_description_into_editor(self, scene_description, project_path, scene_entry=None): def _load_scene_description_into_editor(self, scene_description, project_path, scene_entry=None):
@ -921,6 +1029,14 @@ class ProjectManager:
return False return False
self._clearCurrentScene() self._clearCurrentScene()
self._log_render_runtime_stats("after_clear")
try:
print(
f"[SceneOpen] scene_nodes={len(scene_description.get('nodes', []) or [])} "
f"assets={len(scene_description.get('referenced_asset_guids', []) or [])}"
)
except Exception:
pass
ssbo_loaded = self._load_scene_description_via_ssbo(scene_description, project_path, asset_database) ssbo_loaded = self._load_scene_description_via_ssbo(scene_description, project_path, asset_database)
scene_root, keep_nodes = self._build_scene_root_from_description( scene_root, keep_nodes = self._build_scene_root_from_description(
@ -945,6 +1061,22 @@ class ProjectManager:
except Exception: except Exception:
root_children = [] root_children = []
try:
ssbo_editor = getattr(self.world, "ssbo_editor", None)
runtime_model = getattr(ssbo_editor, "model", None) if ssbo_editor else None
runtime_desc = 0
if runtime_model and not runtime_model.isEmpty():
try:
runtime_desc = runtime_model.find_all_matches("**").get_num_paths()
except Exception:
runtime_desc = 0
print(
f"[SceneOpen] ssbo_loaded={ssbo_loaded} keep_nodes={len(keep_nodes)} "
f"scene_root_children={len(root_children)} runtime_desc={runtime_desc}"
)
except Exception:
pass
for child in root_children: for child in root_children:
child.reparentTo(self.world.render) child.reparentTo(self.world.render)
if child.hasTag("is_model_root") or child.hasTag("asset_guid"): if child.hasTag("is_model_root") or child.hasTag("asset_guid"):
@ -981,6 +1113,28 @@ class ProjectManager:
except Exception: except Exception:
pass pass
try:
render_desc = self.world.render.find_all_matches("**").get_num_paths()
print(
f"[SceneOpen] render_desc={render_desc} built_models={len(built_model_nodes)} "
f"spot={len(built_spot_lights)} point={len(built_point_lights)}"
)
except Exception:
pass
try:
controller = getattr(ssbo_editor, "controller", None) if ssbo_loaded else None
if controller and hasattr(controller, "get_runtime_structure_stats"):
print(f"[SceneOpen] runtime_structure={controller.get_runtime_structure_stats()}")
except Exception:
pass
try:
if ssbo_loaded and ssbo_editor and hasattr(ssbo_editor, "get_source_tree_stats"):
print(f"[SceneOpen] source_tree={ssbo_editor.get_source_tree_stats()}")
except Exception:
pass
self._log_render_runtime_stats("after_scene_rebuild")
scene_components = dict(scene_description.get("scene_components", {}) or {}) scene_components = dict(scene_description.get("scene_components", {}) or {})
camera_state = dict(scene_components.get("camera", {}) or scene_description.get("camera", {}) or {}) camera_state = dict(scene_components.get("camera", {}) or scene_description.get("camera", {}) or {})
camera = getattr(self.world, "camera", None) or getattr(self.world, "cam", None) camera = getattr(self.world, "camera", None) or getattr(self.world, "cam", None)
@ -1008,6 +1162,7 @@ class ProjectManager:
load_lui_fn(temp_stub) load_lui_fn(temp_stub)
except Exception: except Exception:
pass pass
self._log_render_runtime_stats("after_lui_restore")
return bool(ssbo_loaded or built_model_nodes or built_spot_lights or built_point_lights or scene_components) return bool(ssbo_loaded or built_model_nodes or built_spot_lights or built_point_lights or scene_components)
def _iter_top_level_scene_asset_nodes(self, scene_description): def _iter_top_level_scene_asset_nodes(self, scene_description):
@ -1038,7 +1193,7 @@ class ProjectManager:
top_level_nodes.append(node) top_level_nodes.append(node)
return top_level_nodes, node_lookup return top_level_nodes, node_lookup
def _apply_scene_description_state_to_node(self, target_np, node, project_path): def _apply_scene_description_state_to_node(self, target_np, node, project_path, apply_material_state=True):
if not target_np or target_np.isEmpty() or not isinstance(node, dict): if not target_np or target_np.isEmpty() or not isinstance(node, dict):
return return
@ -1049,11 +1204,24 @@ class ProjectManager:
position = list(transform.get("position", [0, 0, 0]) or [0, 0, 0]) position = list(transform.get("position", [0, 0, 0]) or [0, 0, 0])
rotation = list(transform.get("rotation", [0, 0, 0]) or [0, 0, 0]) rotation = list(transform.get("rotation", [0, 0, 0]) or [0, 0, 0])
scale = list(transform.get("scale", [1, 1, 1]) or [1, 1, 1]) scale = list(transform.get("scale", [1, 1, 1]) or [1, 1, 1])
if len(position) >= 3: try:
current_pos = target_np.getPos()
except Exception:
current_pos = None
try:
current_hpr = target_np.getHpr()
except Exception:
current_hpr = None
try:
current_scale = target_np.getScale()
except Exception:
current_scale = None
if len(position) >= 3 and self._vector_differs(current_pos, position):
target_np.setPos(float(position[0]), float(position[1]), float(position[2])) target_np.setPos(float(position[0]), float(position[1]), float(position[2]))
if len(rotation) >= 3: if len(rotation) >= 3 and self._vector_differs(current_hpr, rotation):
target_np.setHpr(float(rotation[0]), float(rotation[1]), float(rotation[2])) target_np.setHpr(float(rotation[0]), float(rotation[1]), float(rotation[2]))
if len(scale) >= 3: if len(scale) >= 3 and self._vector_differs(current_scale, scale):
target_np.setScale(float(scale[0]), float(scale[1]), float(scale[2])) target_np.setScale(float(scale[0]), float(scale[1]), float(scale[2]))
visibility = dict(node.get("visibility", {}) or {}) visibility = dict(node.get("visibility", {}) or {})
@ -1120,7 +1288,8 @@ class ProjectManager:
target_np.setTag("has_scripts", "true") target_np.setTag("has_scripts", "true")
target_np.setTag("scripts_info", json.dumps(scripts, ensure_ascii=False)) target_np.setTag("scripts_info", json.dumps(scripts, ensure_ascii=False))
self._apply_saved_material_tags_to_node(target_np) if apply_material_state:
self._apply_saved_material_tags_to_node(target_np)
def _apply_saved_material_tags_to_node(self, target_np): def _apply_saved_material_tags_to_node(self, target_np):
"""Rebuild runtime material state from serialized material_* tags.""" """Rebuild runtime material state from serialized material_* tags."""
@ -1221,12 +1390,17 @@ class ProjectManager:
except Exception: except Exception:
pass pass
def _apply_scene_description_state_to_subtree(self, target_np, node, project_path, node_lookup): def _apply_scene_description_state_to_subtree(self, target_np, node, project_path, node_lookup, apply_material_state=True):
"""Apply saved state to one imported node and its serialized descendants by child order.""" """Apply saved state to one imported node and its serialized descendants by child order."""
if not target_np or target_np.isEmpty() or not isinstance(node, dict): if not target_np or target_np.isEmpty() or not isinstance(node, dict):
return return
self._apply_scene_description_state_to_node(target_np, node, project_path) self._apply_scene_description_state_to_node(
target_np,
node,
project_path,
apply_material_state=apply_material_state,
)
node_id = str(node.get("node_id", "") or "").strip() node_id = str(node.get("node_id", "") or "").strip()
if not node_id: if not node_id:
@ -1257,7 +1431,13 @@ class ProjectManager:
child_index = _node_index(child_entry) child_index = _node_index(child_entry)
if child_index < 0 or child_index >= len(runtime_children): if child_index < 0 or child_index >= len(runtime_children):
continue continue
self._apply_scene_description_state_to_subtree(runtime_children[child_index], child_entry, project_path, node_lookup) self._apply_scene_description_state_to_subtree(
runtime_children[child_index],
child_entry,
project_path,
node_lookup,
apply_material_state=apply_material_state,
)
def _load_scene_description_via_ssbo(self, scene_description, project_path, asset_database): def _load_scene_description_via_ssbo(self, scene_description, project_path, asset_database):
if not getattr(self.world, "use_ssbo_mouse_picking", False): if not getattr(self.world, "use_ssbo_mouse_picking", False):
@ -1273,7 +1453,7 @@ class ProjectManager:
if not top_level_asset_nodes: if not top_level_asset_nodes:
return False return False
loaded_any = False candidate_nodes = []
for node in top_level_asset_nodes: for node in top_level_asset_nodes:
components = dict(node.get("components", {}) or {}) components = dict(node.get("components", {}) or {})
model_component = dict(components.get("model", {}) or {}) model_component = dict(components.get("model", {}) or {})
@ -1293,6 +1473,13 @@ class ProjectManager:
if not os.path.exists(asset_abs): if not os.path.exists(asset_abs):
continue continue
candidate_nodes.append((node, components, model_component, asset_abs))
loaded_any = False
total_candidates = len(candidate_nodes)
for index, (node, components, model_component, asset_abs) in enumerate(candidate_nodes):
has_more_assets = index < (total_candidates - 1)
has_saved_animation = False has_saved_animation = False
try: try:
metadata_component = dict(components.get("metadata", {}) or {}) metadata_component = dict(components.get("metadata", {}) or {})
@ -1326,6 +1513,7 @@ class ProjectManager:
keep_source_model=False, keep_source_model=False,
append=loaded_any, append=loaded_any,
scene_package_import=False, scene_package_import=False,
rebuild_runtime=False,
) )
except Exception as e: except Exception as e:
print(f"⚠️ SSBO 恢复场景模型失败 {asset_abs}: {e}") print(f"⚠️ SSBO 恢复场景模型失败 {asset_abs}: {e}")
@ -1335,11 +1523,16 @@ class ProjectManager:
if target_root is None: if target_root is None:
continue continue
self._apply_scene_description_state_to_subtree(target_root, node, project_path, node_lookup) self._apply_scene_description_state_to_subtree(
# Keep runtime aligned with source after restoring per-node transforms. target_root,
# Otherwise the next append=True import snapshots stale runtime poses node,
# back into source_model_root and overwrites the restored transform. project_path,
if callable(refresh_runtime_fn): node_lookup,
apply_material_state=False,
)
# Only rebuild between imports, or once after the whole batch below.
# Otherwise reopening a project pays one extra full SSBO rebuild per model.
if has_more_assets and callable(refresh_runtime_fn):
try: try:
refresh_runtime_fn(preserve_selection=False) refresh_runtime_fn(preserve_selection=False)
except Exception: except Exception:
@ -1349,13 +1542,16 @@ class ProjectManager:
if not loaded_any: if not loaded_any:
return False return False
clear_runtime_fn = getattr(ssbo_editor, "_clear_runtime_state", None) if callable(refresh_runtime_fn):
rebuild_fn = getattr(ssbo_editor, "_rebuild_runtime_from_source_root", None)
if callable(rebuild_fn):
try: try:
if callable(clear_runtime_fn): refresh_runtime_fn(preserve_selection=False)
clear_runtime_fn(preserve_source_models=True) except Exception:
rebuild_fn(highlight_root_name=None) pass
force_static_idle_fn = getattr(ssbo_editor, "force_static_chunk_idle_state", None)
if callable(force_static_idle_fn):
try:
force_static_idle_fn()
except Exception: except Exception:
pass pass
@ -1660,6 +1856,24 @@ class ProjectManager:
if skip_asset_nodes: if skip_asset_nodes:
if self._node_belongs_to_asset_hierarchy(node, node_lookup): if self._node_belongs_to_asset_hierarchy(node, node_lookup):
continue continue
imported_node_key = str(
node.get("imported_node_key", "")
or ((node.get("components", {}) or {}).get("model", {}) or {}).get("imported_node_key", "")
or ""
).strip()
if imported_node_key:
continue
if not self._is_scene_description_node_interactive(node):
components = dict(node.get("components", {}) or {})
tags = dict(node.get("tags", {}) or {})
has_light = bool(components.get("light"))
has_gui_tag = (
str(tags.get("is_gui_element", "")).lower() == "true"
or str(tags.get("gui_type", "")).strip() != ""
)
has_scene_element_tag = str(tags.get("is_scene_element", "")).lower() == "true"
if not has_light and not has_gui_tag and not has_scene_element_tag:
continue
is_interactive = self._is_scene_description_node_interactive(node) is_interactive = self._is_scene_description_node_interactive(node)
if include_mode == "interactive" and not is_interactive: if include_mode == "interactive" and not is_interactive:
continue continue

View File

@ -242,12 +242,8 @@ def _resolve_imported_node_key(node, fallback_key: str) -> str:
def _should_serialize_child_nodes(node, asset_guid: str, scripts: list[dict], runtime_interactive: bool, light_component: dict, material_component: dict) -> bool: def _should_serialize_child_nodes(node, asset_guid: str, scripts: list[dict], runtime_interactive: bool, light_component: dict, material_component: dict) -> bool:
if not node: if not node:
return False return False
# Imported model roots and their descendants must keep hierarchy state so
# per-child transforms/material overrides survive save/load.
if asset_guid: if asset_guid:
return True return True
if not asset_guid:
return True
if scripts: if scripts:
return True return True
if runtime_interactive: if runtime_interactive:
@ -256,6 +252,12 @@ def _should_serialize_child_nodes(node, asset_guid: str, scripts: list[dict], ru
return True return True
if material_component: if material_component:
return True return True
if node.hasTag("scene_transform_dirty") and node.getTag("scene_transform_dirty").lower() == "true":
return True
if node.hasTag("scene_material_dirty") and node.getTag("scene_material_dirty").lower() == "true":
return True
if node.hasTag("user_visible") and node.getTag("user_visible").lower() != "true":
return True
return False return False
@ -290,11 +292,61 @@ def _build_scene_description_payload(
referenced_asset_guids = set() referenced_asset_guids = set()
referenced_script_guids = set() referenced_script_guids = set()
subtree_serializable_cache = {}
def _node_is_serializable(node) -> bool:
if not node or node.isEmpty():
return False
cache_key = id(node)
if cache_key in subtree_serializable_cache:
return subtree_serializable_cache[cache_key]
runtime_interactive = (
node.hasTag("runtime_interactive")
and node.getTag("runtime_interactive").lower() == "true"
)
scripts = []
if node.hasTag("scripts_info"):
try:
scripts = json.loads(node.getTag("scripts_info"))
except Exception:
scripts = []
asset_guid = ""
if node.hasTag("is_model_root"):
asset_guid = "root"
light_component = _collect_light_component(node)
material_component = _collect_material_override_component(node)
serializable_here = _should_serialize_child_nodes(
node,
asset_guid,
scripts,
runtime_interactive,
light_component,
material_component,
)
if serializable_here:
subtree_serializable_cache[cache_key] = True
return True
try:
children = list(node.getChildren())
except Exception:
children = []
result = any(_node_is_serializable(child) for child in children if child and not child.isEmpty())
subtree_serializable_cache[cache_key] = result
return result
def walk_nodes(children, parent_id=""): def walk_nodes(children, parent_id=""):
for index, child in enumerate(list(children or [])): for index, child in enumerate(list(children or [])):
if not child or child.isEmpty(): if not child or child.isEmpty():
continue continue
node_id = _node_path_id(parent_id, index) node_id = _node_path_id(parent_id, index)
if not _node_is_serializable(child):
continue
node_name = child.getName() node_name = child.getName()
imported_node_key = _resolve_imported_node_key(child, node_id) imported_node_key = _resolve_imported_node_key(child, node_id)
@ -387,6 +439,9 @@ def _build_scene_description_payload(
runtime_interactive, runtime_interactive,
light_component, light_component,
material_component, material_component,
) or any(
_node_is_serializable(grandchild)
for grandchild in list(child.getChildren()) if grandchild and not grandchild.isEmpty()
): ):
try: try:
child_nodes = list(child.getChildren()) child_nodes = list(child.getChildren())

View File

@ -242,6 +242,81 @@ class ObjectController:
return False return False
def get_runtime_structure_stats(self):
"""Summarize hybrid runtime structure to diagnose idle-state regressions."""
stats = {
"chunks_total": 0,
"chunks_dynamic_enabled": 0,
"chunk_static_nodes": 0,
"chunk_dynamic_nodes": 0,
"chunk_static_visible": 0,
"chunk_static_stashed": 0,
"chunk_dynamic_visible": 0,
"chunk_dynamic_stashed": 0,
"dynamic_object_nodes": 0,
"pick_nodes": 0,
"model_descendants": 0,
"pick_descendants": 0,
"model_geom_nodes": 0,
"pick_geom_nodes": 0,
}
chunks = getattr(self, "chunks", {}) or {}
stats["chunks_total"] = len(chunks)
for chunk in chunks.values():
if not isinstance(chunk, dict):
continue
if chunk.get("dynamic_enabled"):
stats["chunks_dynamic_enabled"] += 1
dynamic_np = chunk.get("dynamic_np")
static_np = chunk.get("static_np")
try:
if dynamic_np and not dynamic_np.is_empty():
stats["chunk_dynamic_nodes"] += 1
try:
if dynamic_np.is_stashed():
stats["chunk_dynamic_stashed"] += 1
elif not dynamic_np.is_hidden():
stats["chunk_dynamic_visible"] += 1
except Exception:
pass
stats["dynamic_object_nodes"] += len(list(dynamic_np.get_children()))
except Exception:
pass
try:
if static_np and not static_np.is_empty():
stats["chunk_static_nodes"] += 1
try:
if static_np.is_stashed():
stats["chunk_static_stashed"] += 1
elif not static_np.is_hidden():
stats["chunk_static_visible"] += 1
except Exception:
pass
except Exception:
pass
model = getattr(self, "model", None)
if model:
try:
if not model.is_empty():
stats["model_descendants"] = model.find_all_matches("**").get_num_paths()
stats["model_geom_nodes"] = model.find_all_matches("**/+GeomNode").get_num_paths()
except Exception:
pass
pick_model = getattr(self, "pick_model", None)
if pick_model:
try:
if not pick_model.is_empty():
stats["pick_descendants"] = pick_model.find_all_matches("**").get_num_paths()
stats["pick_geom_nodes"] = pick_model.find_all_matches("**/+GeomNode").get_num_paths()
stats["pick_nodes"] = len(list(pick_model.get_children()))
except Exception:
pass
return stats
def get_preferred_selection_ids(self, key): def get_preferred_selection_ids(self, key):
"""Return the IDs that should be selected when a tree node is clicked. """Return the IDs that should be selected when a tree node is clicked.

View File

@ -561,101 +561,117 @@ class SSBOEditor:
if not self.controller or not self.model or not self.source_model_root: if not self.controller or not self.model or not self.source_model_root:
return return
proxy = getattr(self, "_group_proxy", None) self._snapshot_active_selection_transform_to_source()
if self._node_is_valid(proxy):
# Persist whole-model transforms that live on the aggregated runtime root.
# Child/object sync above only captures transforms relative to self.model,
# so moving the whole imported model root would otherwise be lost on save.
model_root_mat = None
try:
model_root_mat = LMatrix4f(self.model.get_mat(self.base.render))
except Exception:
try: try:
selection_key = str(proxy.getTag("ssbo_selection_key") or "").strip() model_root_mat = LMatrix4f(self.model.getMat(self.base.render))
except Exception: except Exception:
selection_key = "" model_root_mat = None
if selection_key and selection_key != getattr(self.controller, "tree_root_key", None):
source_group_node = self._resolve_source_node_by_tree_key(selection_key)
if self._node_is_valid(source_group_node):
try:
group_mat = LMatrix4f(proxy.get_mat(self.base.render))
except Exception:
try:
group_mat = LMatrix4f(proxy.getMat(self.base.render))
except Exception:
group_mat = None
if group_mat is not None:
try:
source_group_node.set_mat(group_mat)
except Exception:
try:
source_group_node.setMat(group_mat)
except Exception:
pass
grouped_entries = {} if model_root_mat is None:
for gid, obj_np in self.controller.id_to_object_np.items(): return
if not self._node_is_valid(obj_np):
source_children = self._get_source_root_children()
if not source_children:
return
for source_child in source_children:
if not self._node_is_valid(source_child):
continue continue
child_name = self._get_node_name(source_child, None)
owner_key = self.controller.id_to_name.get(gid) if not child_name:
if not owner_key or owner_key == getattr(self.controller, "tree_root_key", None):
continue continue
base_mat = self._source_child_base_mats.get(child_name)
source_node = self._resolve_source_node_by_tree_key(owner_key) if base_mat is None:
if not self._node_is_valid(source_node):
continue
try:
current_net_mat = LMatrix4f(obj_np.get_mat(self.model))
except Exception:
try: try:
current_net_mat = LMatrix4f(obj_np.getMat(self.model)) base_mat = LMatrix4f(source_child.get_mat())
except Exception:
continue
existing_entry = grouped_entries.get(owner_key)
if existing_entry is None:
grouped_entries[owner_key] = {
"source_node": source_node,
"current_net_mat": current_net_mat,
}
for owner_key in sorted(grouped_entries.keys(), key=lambda key: str(key).count("/")):
entry = grouped_entries.get(owner_key) or {}
source_node = entry.get("source_node")
current_net_mat = entry.get("current_net_mat")
if not self._node_is_valid(source_node) or current_net_mat is None:
continue
try:
source_parent = source_node.get_parent()
except Exception:
try:
source_parent = source_node.getParent()
except Exception:
source_parent = None
parent_net_mat = LMatrix4f.ident_mat()
if self._node_is_valid(source_parent) and source_parent != self.source_model_root:
try:
parent_net_mat = LMatrix4f(source_parent.get_mat(self.source_model_root))
except Exception: except Exception:
try: try:
parent_net_mat = LMatrix4f(source_parent.getMat(self.source_model_root)) base_mat = LMatrix4f(source_child.getMat())
except Exception: except Exception:
parent_net_mat = LMatrix4f.ident_mat() continue
composed_mat = LMatrix4f(base_mat)
inv_parent_mat = LMatrix4f(parent_net_mat) composed_mat *= model_root_mat
try: try:
inv_parent_mat.invertInPlace() source_child.set_mat(composed_mat)
source_child.setTag("scene_transform_dirty", "true")
except Exception: except Exception:
try: try:
inv_parent_mat.invert_in_place() source_child.setMat(composed_mat)
source_child.setTag("scene_transform_dirty", "true")
except Exception: except Exception:
continue continue
local_mat = current_net_mat * inv_parent_mat def _snapshot_active_selection_transform_to_source(self):
"""Persist only the actively edited selection back into the source tree."""
if not self.controller or not self.model or not self.source_model_root:
return False
selected_key = self.selected_name
root_key = getattr(self.controller, "tree_root_key", None)
if not selected_key or selected_key == root_key:
return False
scene_node = self.get_selection_scene_node()
source_node = self.get_selection_source_node()
if not self._node_is_valid(scene_node) or not self._node_is_valid(source_node):
return False
try:
current_net_mat = LMatrix4f(scene_node.get_mat(self.model))
except Exception:
try: try:
source_node.set_mat(local_mat) current_net_mat = LMatrix4f(scene_node.getMat(self.model))
except Exception:
current_net_mat = None
if current_net_mat is None:
return False
try:
source_parent = source_node.get_parent()
except Exception:
try:
source_parent = source_node.getParent()
except Exception:
source_parent = None
parent_net_mat = LMatrix4f.ident_mat()
if self._node_is_valid(source_parent) and source_parent != self.source_model_root:
try:
parent_net_mat = LMatrix4f(source_parent.get_mat(self.source_model_root))
except Exception: except Exception:
try: try:
source_node.setMat(local_mat) parent_net_mat = LMatrix4f(source_parent.getMat(self.source_model_root))
except Exception: except Exception:
continue parent_net_mat = LMatrix4f.ident_mat()
inv_parent_mat = LMatrix4f(parent_net_mat)
try:
inv_parent_mat.invertInPlace()
except Exception:
try:
inv_parent_mat.invert_in_place()
except Exception:
return False
local_mat = current_net_mat * inv_parent_mat
try:
source_node.set_mat(local_mat)
source_node.setTag("scene_transform_dirty", "true")
except Exception:
try:
source_node.setMat(local_mat)
source_node.setTag("scene_transform_dirty", "true")
except Exception:
return False
return True
def _resolve_source_node_by_tree_key(self, tree_key): def _resolve_source_node_by_tree_key(self, tree_key):
"""Resolve controller tree key (e.g. 0/1/2) to source_model_root node.""" """Resolve controller tree key (e.g. 0/1/2) to source_model_root node."""
@ -1115,6 +1131,7 @@ class SSBOEditor:
target_snapshot = _clone_snapshot_for_target(source_snapshot, source_node) target_snapshot = _clone_snapshot_for_target(source_snapshot, source_node)
if target_snapshot is not None: if target_snapshot is not None:
apply_snapshot_fn(source_node, target_snapshot) apply_snapshot_fn(source_node, target_snapshot)
source_node.setTag("scene_material_dirty", "true")
synced += 1 synced += 1
continue continue
except Exception: except Exception:
@ -1145,6 +1162,7 @@ class SSBOEditor:
try: try:
if callable(set_src_state): if callable(set_src_state):
set_src_state(src_geom_index, runtime_geom_state) set_src_state(src_geom_index, runtime_geom_state)
source_node.setTag("scene_material_dirty", "true")
synced += 1 synced += 1
except Exception: except Exception:
continue continue
@ -1223,6 +1241,7 @@ class SSBOEditor:
self.clear_selection() self.clear_selection()
self._cleanup_group_proxy() self._cleanup_group_proxy()
self._reset_pick_sync_cache() self._reset_pick_sync_cache()
self._teardown_gpu_picking()
controller = self.controller controller = self.controller
pick_model = getattr(controller, "pick_model", None) if controller else None pick_model = getattr(controller, "pick_model", None) if controller else None
@ -1440,6 +1459,7 @@ class SSBOEditor:
keep_source_model=False, keep_source_model=False,
append=False, append=False,
scene_package_import=False, scene_package_import=False,
rebuild_runtime=True,
): ):
"""Load and process one model into the aggregated SSBO scene.""" """Load and process one model into the aggregated SSBO scene."""
print(f"[SSBOEditor] Loading model: {model_path}") print(f"[SSBOEditor] Loading model: {model_path}")
@ -1511,7 +1531,8 @@ class SSBOEditor:
self.source_model = source_root self.source_model = source_root
self._restore_saved_material_bindings_from_tags(source_root) self._restore_saved_material_bindings_from_tags(source_root)
self._capture_source_child_base_mats() self._capture_source_child_base_mats()
self._rebuild_runtime_from_source_root(highlight_root_name=None) if rebuild_runtime:
self._rebuild_runtime_from_source_root(highlight_root_name=None)
if len(imported_roots) == 1: if len(imported_roots) == 1:
return imported_roots[0] return imported_roots[0]
return source_root return source_root
@ -1529,7 +1550,8 @@ class SSBOEditor:
self.source_model = source_root self.source_model = source_root
self._capture_source_child_base_mats() self._capture_source_child_base_mats()
self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name) if rebuild_runtime:
self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name)
return imported_root return imported_root
def _build_filename_candidates(self, path_text): def _build_filename_candidates(self, path_text):
@ -2106,7 +2128,8 @@ class SSBOEditor:
def setup_gpu_picking(self): def setup_gpu_picking(self):
"""Setup GPU Picking (Basic implementation)""" """Setup GPU Picking (Basic implementation)"""
# ... (Buffer setup code remains same) ... self._teardown_gpu_picking()
win_props = WindowProperties() win_props = WindowProperties()
win_props.set_size(1, 1) win_props.set_size(1, 1)
fb_props = FrameBufferProperties() fb_props = FrameBufferProperties()
@ -2169,6 +2192,35 @@ class SSBOEditor:
self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0)) self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0))
self.pick_buffer.set_clear_color_active(True) self.pick_buffer.set_clear_color_active(True)
def _teardown_gpu_picking(self):
"""Release old GPU picking resources before rebuilding them."""
pick_cam_np = getattr(self, "pick_cam_np", None)
if pick_cam_np is not None:
try:
if not pick_cam_np.is_empty():
pick_cam_np.remove_node()
except Exception:
try:
if not pick_cam_np.isEmpty():
pick_cam_np.removeNode()
except Exception:
pass
self.pick_cam_np = None
self.pick_cam = None
self.pick_lens = None
pick_buffer = getattr(self, "pick_buffer", None)
if pick_buffer is not None:
try:
self.base.graphicsEngine.remove_window(pick_buffer)
except Exception:
try:
self.base.graphicsEngine.removeWindow(pick_buffer)
except Exception:
pass
self.pick_buffer = None
self.pick_texture = None
def _sync_pick_model_transform(self): def _sync_pick_model_transform(self):
"""Sync pick-scene clone to current source model world transform.""" """Sync pick-scene clone to current source model world transform."""
if not self.controller or not self.model: if not self.controller or not self.model:
@ -2744,6 +2796,7 @@ class SSBOEditor:
try: try:
self._clear_runtime_state(preserve_source_models=True) self._clear_runtime_state(preserve_source_models=True)
self._rebuild_runtime_from_source_root(highlight_root_name=None) self._rebuild_runtime_from_source_root(highlight_root_name=None)
self.force_static_chunk_idle_state()
if preserve_selection and selected_key and self.controller: if preserve_selection and selected_key and self.controller:
try: try:
if ( if (
@ -2759,6 +2812,74 @@ class SSBOEditor:
print(f"[SSBOEditor] 从 source 刷新 runtime 失败: {e}") print(f"[SSBOEditor] 从 source 刷新 runtime 失败: {e}")
return False return False
def get_source_tree_stats(self):
root = getattr(self, "source_model_root", None)
stats = {
"valid": False,
"has_parent": False,
"hidden": False,
"stashed": False,
"descendants": 0,
"geom_nodes": 0,
"top_children": 0,
"parent_name": "",
}
if not self._node_is_valid(root):
return stats
stats["valid"] = True
try:
stats["has_parent"] = bool(root.has_parent())
except Exception:
pass
try:
stats["hidden"] = bool(root.is_hidden())
except Exception:
pass
try:
stats["stashed"] = bool(root.is_stashed())
except Exception:
pass
try:
parent = root.get_parent()
if parent and not parent.is_empty():
stats["parent_name"] = parent.get_name() or ""
except Exception:
pass
try:
stats["descendants"] = root.find_all_matches("**").get_num_paths()
except Exception:
pass
try:
stats["geom_nodes"] = len(list(root.find_all_matches("**/+GeomNode")))
except Exception:
pass
try:
stats["top_children"] = len([child for child in root.get_children() if not child.is_empty()])
except Exception:
pass
return stats
def force_static_chunk_idle_state(self):
"""Force hybrid chunk runtime back to fully static idle mode."""
controller = getattr(self, "controller", None)
chunks = getattr(controller, "chunks", None) if controller else None
if not controller or not isinstance(chunks, dict) or not chunks:
return False
rebuilt = 0
for chunk_id in sorted(chunks):
try:
controller._rebuild_static_chunk(chunk_id)
controller._set_chunk_dynamic(chunk_id, False)
rebuilt += 1
except Exception:
continue
if rebuilt:
print(f"[SSBOEditor] Forced static idle chunks: {rebuilt}")
return rebuilt > 0
def _sync_editor_selection_reference(self, node): def _sync_editor_selection_reference(self, node):
selection = getattr(self.base, "selection", None) selection = getattr(self.base, "selection", None)
if not selection: if not selection:
@ -2819,6 +2940,7 @@ class SSBOEditor:
self.clear_selection(sync_world_selection=False) self.clear_selection(sync_world_selection=False)
def clear_selection(self, sync_world_selection=True): def clear_selection(self, sync_world_selection=True):
self._snapshot_active_selection_transform_to_source()
self._stop_pick_sync_task() self._stop_pick_sync_task()
self._reset_pick_sync_cache() self._reset_pick_sync_cache()
self._cleanup_group_proxy() self._cleanup_group_proxy()
@ -2877,6 +2999,7 @@ class SSBOEditor:
pass # No selection mask texture needed without custom shader pass # No selection mask texture needed without custom shader
def select_node(self, key, sync_world_selection=True): def select_node(self, key, sync_world_selection=True):
self._snapshot_active_selection_transform_to_source()
# Clean up previous group proxy before changing selection # Clean up previous group proxy before changing selection
self._cleanup_group_proxy() self._cleanup_group_proxy()
self._reset_pick_sync_cache() self._reset_pick_sync_cache()

View File

@ -1540,6 +1540,10 @@ class AppActions:
SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager). SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager).
Legacy mode: load via SceneManager. Legacy mode: load via SceneManager.
""" """
try:
self._scene_tree_epoch = int(getattr(self, "_scene_tree_epoch", 0) or 0) + 1
except Exception:
self._scene_tree_epoch = 1
animated_model = bool(file_path and self._model_file_has_animation(file_path)) animated_model = bool(file_path and self._model_file_has_animation(file_path))
if animated_model: if animated_model:
prefer_scene_manager = True prefer_scene_manager = True
@ -1584,6 +1588,77 @@ class AppActions:
return self.scene_manager.importModel(file_path) return self.scene_manager.importModel(file_path)
return None return None
def _log_render_runtime_stats(self, label):
"""Print renderer/task stats for import/open path comparison."""
try:
win = getattr(self, "win", None)
num_regions = win.getNumDisplayRegions() if win else 0
region_lines = []
if win:
for index in range(num_regions):
try:
dr = win.getDisplayRegion(index)
camera = dr.getCamera()
camera_name = "None"
if camera and not camera.isEmpty():
camera_name = camera.getName()
region_lines.append(
f"{index}:{dr.getSort()}:{int(bool(dr.isActive()))}:{camera_name}"
)
except Exception:
continue
graphics_engine = getattr(self, "graphicsEngine", None)
num_windows = graphics_engine.getNumWindows() if graphics_engine else 0
render = getattr(self, "render", None)
camera_count = 0
special_camera_counts = {}
if render and not render.isEmpty():
try:
camera_count = render.find_all_matches("**/+Camera").get_num_paths()
except Exception:
camera_count = 0
for camera_name in (
"gizmo_overlay_cam",
"pick_camera",
"selection_outline_mask_camera",
):
try:
special_camera_counts[camera_name] = render.find_all_matches(
f"**/{camera_name}"
).get_num_paths()
except Exception:
special_camera_counts[camera_name] = 0
task_mgr = getattr(self, "taskMgr", None) or getattr(self, "task_mgr", None)
task_names = []
if task_mgr:
try:
task_names = [task.name for task in list(task_mgr.getTasks())]
except Exception:
task_names = []
interesting_tasks = [
name for name in task_names
if (
"gizmo" in str(name).lower()
or "outline" in str(name).lower()
or "pick" in str(name).lower()
or "lui" in str(name).lower()
or "canvas" in str(name).lower()
)
]
print(
f"[RenderStats:{label}] regions={num_regions} windows={num_windows} "
f"render_cameras={camera_count} special={special_camera_counts} "
f"interesting_tasks={interesting_tasks}"
)
if region_lines:
print(f"[RenderStats:{label}] region_detail={' | '.join(region_lines)}")
except Exception:
pass
def _import_model_with_menu_logic( def _import_model_with_menu_logic(
self, self,
file_path, file_path,
@ -1667,6 +1742,16 @@ class AppActions:
if show_success_message: if show_success_message:
self.add_success_message(f"模型导入成功: {file_name}") self.add_success_message(f"模型导入成功: {file_name}")
try:
ssbo_editor = getattr(self, "ssbo_editor", None)
controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None
if controller and hasattr(controller, "get_runtime_structure_stats"):
print(f"[ManualImport] runtime_structure={controller.get_runtime_structure_stats()}")
if ssbo_editor and hasattr(ssbo_editor, "get_source_tree_stats"):
print(f"[ManualImport] source_tree={ssbo_editor.get_source_tree_stats()}")
except Exception:
pass
self._log_render_runtime_stats("manual_import")
return model_node return model_node
except Exception as e: except Exception as e:
self.add_error_message(f"导入模型失败: {e}") self.add_error_message(f"导入模型失败: {e}")

View File

@ -155,6 +155,12 @@ class EditorPanelsLeftMixin:
count += 1 count += 1
return count return count
def _get_scene_tree_epoch(self):
try:
return int(getattr(self.app, "_scene_tree_epoch", 0) or 0)
except Exception:
return 0
def _ssbo_key_matches_scene_filter(self, controller, key): def _ssbo_key_matches_scene_filter(self, controller, key):
filter_text = self._get_scene_tree_filter() filter_text = self._get_scene_tree_filter()
if not filter_text: if not filter_text:
@ -446,10 +452,11 @@ class EditorPanelsLeftMixin:
obj_count = len(controller.name_to_ids.get(key, [])) obj_count = len(controller.name_to_ids.get(key, []))
children = node_data["children"] children = node_data["children"]
is_selected = (getattr(ssbo_editor, "selected_name", None) == key) is_selected = (getattr(ssbo_editor, "selected_name", None) == key)
tree_epoch = self._get_scene_tree_epoch()
if not children: if not children:
# Leaf node: selectable # Leaf node: selectable
label = f"{display} ({obj_count})##{key}" label = f"{display} ({obj_count})##{tree_epoch}:{key}"
if imgui.selectable(label, is_selected)[0]: if imgui.selectable(label, is_selected)[0]:
ssbo_editor.select_node(key) ssbo_editor.select_node(key)
if hasattr(self.app, "lui_manager"): if hasattr(self.app, "lui_manager"):
@ -465,7 +472,7 @@ class EditorPanelsLeftMixin:
flags = imgui.TreeNodeFlags_.open_on_arrow flags = imgui.TreeNodeFlags_.open_on_arrow
if is_selected: if is_selected:
flags |= imgui.TreeNodeFlags_.selected flags |= imgui.TreeNodeFlags_.selected
label = f"{display} ({obj_count})##{key}" label = f"{display} ({obj_count})##{tree_epoch}:{key}"
opened = imgui.tree_node_ex(label, flags) opened = imgui.tree_node_ex(label, flags)
if imgui.is_item_clicked(0): if imgui.is_item_clicked(0):
ssbo_editor.select_node(key) ssbo_editor.select_node(key)
@ -495,6 +502,7 @@ class EditorPanelsLeftMixin:
obj_count = len(controller.name_to_ids.get(key, [])) obj_count = len(controller.name_to_ids.get(key, []))
is_selected = (getattr(ssbo_editor, "selected_name", None) == key) is_selected = (getattr(ssbo_editor, "selected_name", None) == key)
children = list((virtual_node.get("children", {}) or {}).values()) children = list((virtual_node.get("children", {}) or {}).values())
tree_epoch = self._get_scene_tree_epoch()
if display.strip().lower() == "root" and children: if display.strip().lower() == "root" and children:
for child in children: for child in children:
@ -504,7 +512,7 @@ class EditorPanelsLeftMixin:
return return
if not children: if not children:
label = f"{display} ({obj_count})##{key}" label = f"{display} ({obj_count})##{tree_epoch}:{key}"
if imgui.selectable(label, is_selected)[0]: if imgui.selectable(label, is_selected)[0]:
ssbo_editor.select_node(key) ssbo_editor.select_node(key)
if hasattr(self.app, "lui_manager"): if hasattr(self.app, "lui_manager"):
@ -520,7 +528,7 @@ class EditorPanelsLeftMixin:
flags = imgui.TreeNodeFlags_.open_on_arrow flags = imgui.TreeNodeFlags_.open_on_arrow
if is_selected: if is_selected:
flags |= imgui.TreeNodeFlags_.selected flags |= imgui.TreeNodeFlags_.selected
label = f"{display} ({obj_count})##{key}" label = f"{display} ({obj_count})##{tree_epoch}:{key}"
opened = imgui.tree_node_ex(label, flags) opened = imgui.tree_node_ex(label, flags)
if imgui.is_item_clicked(0): if imgui.is_item_clicked(0):
ssbo_editor.select_node(key) ssbo_editor.select_node(key)