SSBO加载模型提高帧率,解决动画播放BUG

This commit is contained in:
Hector 2026-03-19 10:55:42 +08:00
parent d609cb5f39
commit 46fa1756a1
7 changed files with 502 additions and 37 deletions

View File

@ -2089,6 +2089,13 @@ class SelectionSystem:
node_name = nodePath.getName()
#print(f"新选择的节点: {node_name}")
animation_tools = getattr(self.world, "animation_tools", None)
if animation_tools and hasattr(animation_tools, "_stop_all_active_animations"):
try:
animation_tools._stop_all_active_animations()
except Exception as e:
print(f"停止活动动画失败: {e}")
ssbo_editor = getattr(self.world, "ssbo_editor", None)
if ssbo_editor:
try:

View File

@ -551,6 +551,7 @@ class ObjectController:
# Even medium flattening can still collapse or rewrite per-object PBR/effect
# state after save/load, which manifests as black materials until selection
# switches back to the dynamic objects.
static_np.flatten_strong()
chunk["static_np"] = static_np
chunk["dirty"] = False

View File

@ -1033,6 +1033,14 @@ class SSBOEditor:
self._set_node_name(source_model, unique_root_name)
imported_root = source_model.copyTo(source_root)
self._set_node_name(imported_root, unique_root_name)
try:
imported_root.setTag("model_path", model_path)
imported_root.setTag("original_path", model_path)
imported_root.setTag("saved_model_path", model_path)
imported_root.setTag("is_model_root", "1")
imported_root.setTag("tree_item_type", "IMPORTED_MODEL_NODE")
except Exception:
pass
if keep_source_model and not append:
self.source_model = imported_root
@ -2110,6 +2118,35 @@ class SSBOEditor:
"is_group": len(self.selected_ids) > 1 and not self._is_root_selection(),
}
def estimate_selection_cost(self, key):
"""Estimate how expensive it is to activate a tree node in dynamic mode."""
if not self.controller or key is None:
return None
try:
object_ids = list(self.controller.name_to_ids.get(key, []))
except Exception:
object_ids = []
chunk_ids = set()
for gid in object_ids:
try:
chunk_id = self.controller.id_to_chunk.get(gid)
except Exception:
chunk_id = None
if chunk_id is not None:
chunk_ids.add(chunk_id)
return {
"key": key,
"object_count": len(object_ids),
"chunk_count": len(chunk_ids),
"is_root": bool(
self.controller and
key == getattr(self.controller, "tree_root_key", None)
),
}
def _sync_editor_selection_reference(self, node):
selection = getattr(self.base, "selection", None)
if not selection:

View File

@ -339,6 +339,7 @@ class AnimationTools:
if not related_found and node_name:
fallback_best = None
fallback_score = -1
fallback_count = 0
for model in list(models):
try:
if not model or model.isEmpty() or self._is_scene_root_node(model):
@ -358,12 +359,13 @@ class AnimationTools:
score += 80 if has_anim and has_geom else 0
score += 40 if has_model_path else 0
score += 25 if has_saved_path else 0
fallback_count += 1
if score > fallback_score:
fallback_score = score
fallback_best = model
except Exception:
continue
if fallback_best:
if fallback_best and fallback_count == 1:
return fallback_best
# 单模型场景兜底
@ -549,12 +551,214 @@ class AnimationTools:
except Exception:
return ""
def _resolve_ssbo_source_owner(self, node):
"""将 SSBO 运行时选择节点映射回 source_model_root 中的真实模型根。"""
try:
ssbo_editor = getattr(self, "ssbo_editor", None)
if not ssbo_editor:
return None
source_root = getattr(ssbo_editor, "source_model_root", None)
if not source_root:
return None
try:
if source_root.isEmpty():
return None
except Exception:
return None
selection_key = None
try:
if node and (not node.isEmpty()) and node.hasTag("ssbo_selection_key"):
selection_key = node.getTag("ssbo_selection_key")
except Exception:
selection_key = None
if not selection_key:
try:
selection_key = ssbo_editor._find_tree_key_for_scene_node(node)
except Exception:
selection_key = None
if not selection_key:
selection_key = getattr(ssbo_editor, "selected_name", None)
if not selection_key:
return None
try:
source_owner = ssbo_editor._resolve_source_node_by_tree_key(selection_key)
except Exception:
source_owner = None
if not source_owner:
return None
try:
if source_owner.isEmpty():
return None
except Exception:
return None
try:
if node and (not node.isEmpty()):
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
if (
(not source_owner.hasTag(tag_name) or not source_owner.getTag(tag_name))
and node.hasTag(tag_name)
and node.getTag(tag_name)
):
source_owner.setTag(tag_name, node.getTag(tag_name))
except Exception:
pass
return source_owner
except Exception:
return None
def _resolve_animation_owner_model(self, node):
"""向上查找动画所属的模型根节点,避免选中子节点时绑定失败。"""
ssbo_owner = self._resolve_ssbo_source_owner(node)
if ssbo_owner and not ssbo_owner.isEmpty():
try:
if node and not node.isEmpty():
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
if (
(not node.hasTag(tag_name) or not node.getTag(tag_name))
and ssbo_owner.hasTag(tag_name)
and ssbo_owner.getTag(tag_name)
):
node.setTag(tag_name, ssbo_owner.getTag(tag_name))
try:
node.setPythonTag("animation_source_owner", ssbo_owner)
except Exception:
pass
return node
except Exception:
pass
return ssbo_owner
scene_owner = self._find_scene_model_owner(node)
chain_best = None
try:
current = node
max_depth = 64
chain = []
for _ in range(max_depth):
if not current or current.isEmpty():
break
# 绝不把场景根节点当作动画 owner否则 hide() 会把全场景隐藏
if self._is_scene_root_node(current):
break
chain.append(current)
parent = current.getParent()
if not parent or parent.isEmpty() or parent == current:
break
current = parent
# 标签缺失/层级复杂时:按可见播放优先级打分选 owner动画+几何体最高)
if chain:
path_source = None
original_path_source = None
def score_candidate(c):
try:
has_character = c.findAllMatches("**/+Character").getNumPaths() > 0
has_bundle = c.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0
has_anim = has_character or has_bundle
has_geom = c.findAllMatches("**/+GeomNode").getNumPaths() > 0
except Exception:
has_anim = False
has_geom = False
has_model_path = c.hasTag("model_path") and bool(c.getTag("model_path"))
has_saved_path = c.hasTag("saved_model_path") and bool(c.getTag("saved_model_path"))
has_original_path = c.hasTag("original_path") and bool(c.getTag("original_path"))
is_model_root = c.hasTag("is_model_root") and c.getTag("is_model_root") == "1"
is_imported_root = c.hasTag("tree_item_type") and c.getTag("tree_item_type") == "IMPORTED_MODEL_NODE"
# 分数越高越优先:保证“具体模型根”优先于聚合场景根
score = 0
score += 140 if has_anim and has_geom else 0
score += 90 if has_model_path and has_geom else 0
score += 55 if has_saved_path and has_geom else 0
score += 45 if has_original_path and has_geom else 0
score += 40 if has_anim else 0
score += 20 if has_geom else 0
score += 25 if has_model_path else 0
score += 15 if has_saved_path else 0
score += 12 if has_original_path else 0
score += 10 if is_model_root else 0
score += 8 if is_imported_root else 0
return score
best = chain[0]
best_score = -1
for candidate in chain:
try:
if not path_source and candidate.hasTag("model_path") and candidate.getTag("model_path"):
path_source = candidate
if not original_path_source and candidate.hasTag("original_path") and candidate.getTag("original_path"):
original_path_source = candidate
except Exception:
pass
s = score_candidate(candidate)
if s > best_score:
best_score = s
best = candidate
# 把路径标签补到最终 owner避免后续格式识别为“未知”
try:
if path_source and ((not best.hasTag("model_path")) or (not best.getTag("model_path"))):
best.setTag("model_path", path_source.getTag("model_path"))
if original_path_source and ((not best.hasTag("original_path")) or (not best.getTag("original_path"))):
best.setTag("original_path", original_path_source.getTag("original_path"))
if best.hasTag("saved_model_path") and ((not best.hasTag("model_path")) or (not best.getTag("model_path"))):
best.setTag("model_path", best.getTag("saved_model_path"))
except Exception:
pass
chain_best = best
except Exception:
chain_best = None
if scene_owner and not scene_owner.isEmpty():
try:
# SSBO 聚合场景下scene_manager.models 常常只有总根节点。
# 如果祖先链里存在更具体、带路径/动画/几何体的节点,应优先使用它,
# 否则会退回到场景级内存代理,导致播放时原模型不隐藏、位置不同步。
if chain_best and chain_best != scene_owner:
scene_score = 0
chain_score = 0
for candidate, target in ((scene_owner, "scene"), (chain_best, "chain")):
try:
has_anim = (
candidate.findAllMatches("**/+Character").getNumPaths() > 0 or
candidate.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0
)
has_geom = candidate.findAllMatches("**/+GeomNode").getNumPaths() > 0
has_path = (
(candidate.hasTag("model_path") and bool(candidate.getTag("model_path"))) or
(candidate.hasTag("saved_model_path") and bool(candidate.getTag("saved_model_path"))) or
(candidate.hasTag("original_path") and bool(candidate.getTag("original_path")))
)
score = 0
score += 140 if has_anim and has_geom else 0
score += 60 if has_path and has_geom else 0
score += 25 if has_anim else 0
score += 15 if has_geom else 0
score += 20 if has_path else 0
except Exception:
score = 0
if target == "scene":
scene_score = score
else:
chain_score = score
if chain_score > scene_score:
scene_owner = chain_best
# 同步路径标签,避免后续格式识别为“未知”
if scene_owner.hasTag("model_path") and scene_owner.getTag("model_path"):
if (not node.hasTag("model_path")) or (not node.getTag("model_path")):
@ -1317,6 +1521,146 @@ class AnimationTools:
return current_anim
def _cleanup_conflicting_animation_actors(self, owner_model):
"""Remove stale cached actors that belong to the same logical model."""
try:
owner_path_candidates = set()
for tag_name in ("model_path", "original_path", "saved_model_path"):
try:
if owner_model.hasTag(tag_name):
value = owner_model.getTag(tag_name)
if value:
owner_path_candidates.add(os.path.normcase(os.path.normpath(value)))
except Exception:
continue
for cached_owner, actor in list(self._actor_cache.items()):
if cached_owner == owner_model:
continue
related = False
try:
related = (
cached_owner == owner_model or
cached_owner.isAncestorOf(owner_model) or
owner_model.isAncestorOf(cached_owner)
)
except Exception:
related = False
if (not related) and owner_path_candidates:
for tag_name in ("model_path", "original_path", "saved_model_path"):
try:
if not cached_owner.hasTag(tag_name):
continue
value = cached_owner.getTag(tag_name)
if not value:
continue
if os.path.normcase(os.path.normpath(value)) in owner_path_candidates:
related = True
break
except Exception:
continue
if not related:
continue
try:
taskMgr.remove(f"maintain_anim_pos_{id(actor)}")
except Exception:
pass
try:
if self._should_swap_visibility_for_actor(cached_owner, actor):
cached_owner.show()
except Exception:
pass
try:
actor.hide()
except Exception:
pass
try:
if hasattr(actor, "cleanup"):
actor.cleanup()
except Exception:
pass
try:
if hasattr(actor, "removeNode"):
actor.removeNode()
except Exception:
pass
try:
del self._actor_cache[cached_owner]
except Exception:
pass
except Exception:
pass
def _stop_all_active_animations(self, except_owner=None):
"""Destroy all cached animation actors except the optional owner."""
try:
for cached_owner in list(self._actor_cache.keys()):
try:
if except_owner is not None and cached_owner == except_owner:
continue
except Exception:
pass
self._discard_cached_actor(cached_owner, restore_owner=True)
except Exception:
pass
def _discard_cached_actor(self, owner_model, restore_owner=True):
"""Destroy one owner's cached actor before rebuilding it."""
try:
actor = self._actor_cache.get(owner_model)
if not actor:
return
try:
actor.stop()
except Exception:
pass
try:
taskMgr.remove(f"maintain_anim_pos_{id(actor)}")
except Exception:
pass
try:
actor.hide()
except Exception:
pass
if restore_owner:
try:
if owner_model and not owner_model.isEmpty():
owner_model.show()
except Exception:
pass
try:
if hasattr(actor, "cleanup"):
actor.cleanup()
except Exception:
pass
try:
if hasattr(actor, "removeNode"):
actor.removeNode()
except Exception:
pass
try:
del self._actor_cache[owner_model]
except Exception:
pass
except Exception:
pass
def _should_swap_visibility_for_actor(self, owner_model, actor):
"""
是否需要隐藏原模型/显示Actor的切换
@ -1350,6 +1694,9 @@ class AnimationTools:
pass
owner_model = self._resolve_animation_owner_model(origin_model)
self._stop_all_active_animations()
self._cleanup_conflicting_animation_actors(owner_model)
self._discard_cached_actor(owner_model, restore_owner=True)
actor = self._getActor(owner_model)
if not actor:
return
@ -1373,9 +1720,14 @@ class AnimationTools:
is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False))
original_world_pos = owner_model.getPos(self.render)
original_world_hpr = owner_model.getHpr(self.render)
original_world_scale = owner_model.getScale(self.render)
if not is_scene_bound_proxy:
actor.setPos(self.render, owner_model.getPos(self.render))
actor.setHpr(self.render, owner_model.getHpr(self.render))
actor.setScale(self.render, owner_model.getScale(self.render))
# original_world_pos = owner_model.getPos(self.render)
# original_world_hpr = owner_model.getHpr(self.render)
# original_world_scale = owner_model.getScale(self.render)
if not is_scene_bound_proxy:
actor.setPos(owner_model.getPos())
@ -1394,9 +1746,9 @@ class AnimationTools:
def maintainWorldPosition(task):
try:
if not actor.isEmpty() and not owner_model.isEmpty():
actor.setPos(self.render, original_world_pos)
actor.setHpr(self.render, original_world_hpr)
actor.setScale(self.render, original_world_scale)
actor.setPos(self.render, owner_model.getPos(self.render))
actor.setHpr(self.render, owner_model.getHpr(self.render))
actor.setScale(self.render, owner_model.getScale(self.render))
return task.cont
else:
return task.done
@ -1497,6 +1849,9 @@ class AnimationTools:
pass
owner_model = self._resolve_animation_owner_model(origin_model)
self._stop_all_active_animations()
self._cleanup_conflicting_animation_actors(owner_model)
self._discard_cached_actor(owner_model, restore_owner=True)
actor = self._getActor(owner_model)
if not actor:
return
@ -1508,9 +1863,9 @@ class AnimationTools:
original_world_scale = owner_model.getScale(self.render)
if not is_scene_bound_proxy:
actor.setPos(owner_model.getPos())
actor.setHpr(owner_model.getHpr())
actor.setScale(owner_model.getScale())
actor.setPos(self.render, owner_model.getPos(self.render))
actor.setHpr(self.render, owner_model.getHpr(self.render))
actor.setScale(self.render, owner_model.getScale(self.render))
if self._should_swap_visibility_for_actor(owner_model, actor):
owner_model.hide()
@ -1523,9 +1878,9 @@ class AnimationTools:
def maintainWorldPosition(task):
try:
if not actor.isEmpty() and not owner_model.isEmpty():
actor.setPos(self.render, original_world_pos)
actor.setHpr(self.render, original_world_hpr)
actor.setScale(self.render, original_world_scale)
actor.setPos(self.render, owner_model.getPos(self.render))
actor.setHpr(self.render, owner_model.getHpr(self.render))
actor.setScale(self.render, owner_model.getScale(self.render))
return task.cont
else:
return task.done

View File

@ -1497,7 +1497,19 @@ class AppActions:
if hasattr(self.scene_manager, 'models'):
if getattr(self, "use_ssbo_mouse_picking", False):
self.scene_manager.models = [model_node]
try:
existing_models = []
for existing in getattr(self.scene_manager, "models", []):
try:
if existing and not existing.isEmpty():
existing_models.append(existing)
except Exception:
continue
if model_node and all(existing != model_node for existing in existing_models):
existing_models.append(model_node)
self.scene_manager.models = existing_models
except Exception:
self.scene_manager.models = [model_node] if model_node else []
elif model_node not in self.scene_manager.models:
self.scene_manager.models.append(model_node)
@ -1507,7 +1519,33 @@ class AppActions:
and getattr(self, "ssbo_editor", None)
and getattr(self.ssbo_editor, "last_import_tree_key", None)
):
self.ssbo_editor.select_node(self.ssbo_editor.last_import_tree_key)
selected_key = self.ssbo_editor.last_import_tree_key
selection_cost = None
if hasattr(self.ssbo_editor, "estimate_selection_cost"):
try:
selection_cost = self.ssbo_editor.estimate_selection_cost(selected_key)
except Exception:
selection_cost = None
should_auto_select = True
if selection_cost:
object_count = int(selection_cost.get("object_count", 0))
chunk_count = int(selection_cost.get("chunk_count", 0))
is_root = bool(selection_cost.get("is_root"))
# SSBO 导入后的顶层节点通常覆盖整片模型;
# 只要它跨多个对象或多个 chunk被自动选中后就会让静态批处理失效。
# 因此这里只允许极小代价的单对象选择自动激活,其余统一保持未选中。
if is_root or object_count > 1 or chunk_count > 1:
should_auto_select = False
if should_auto_select:
self.ssbo_editor.select_node(selected_key)
else:
self.ssbo_editor.clear_selection(sync_world_selection=False)
if hasattr(self.ssbo_editor, "_sync_editor_selection_reference"):
self.ssbo_editor._sync_editor_selection_reference(None)
if show_info_message:
self.add_info_message("模型导入完成,已保持 SSBO 静态模式以避免导入后掉帧")
elif hasattr(self, 'selection') and self.selection:
self.selection.updateSelection(model_node)

View File

@ -620,21 +620,23 @@ class EditorPanelsRightMixin(
needs_path = (not anim_node.hasTag("model_path")) or (not anim_node.getTag("model_path"))
if needs_path and hasattr(self, "scene_manager") and self.scene_manager:
models = getattr(self.scene_manager, "models", [])
matched_models = []
for model in list(models):
try:
if not model or model.isEmpty():
continue
if (model == anim_node or model.isAncestorOf(anim_node) or anim_node.isAncestorOf(model)):
if model.hasTag("model_path") and model.getTag("model_path"):
anim_node.setTag("model_path", model.getTag("model_path"))
if model.hasTag("original_path") and model.getTag("original_path"):
anim_node.setTag("original_path", model.getTag("original_path"))
break
if model.hasTag("saved_model_path") and model.getTag("saved_model_path"):
anim_node.setTag("model_path", model.getTag("saved_model_path"))
break
matched_models.append(model)
except Exception:
continue
if len(matched_models) == 1:
model = matched_models[0]
if model.hasTag("model_path") and model.getTag("model_path"):
anim_node.setTag("model_path", model.getTag("model_path"))
if model.hasTag("original_path") and model.getTag("original_path"):
anim_node.setTag("original_path", model.getTag("original_path"))
elif model.hasTag("saved_model_path") and model.getTag("saved_model_path"):
anim_node.setTag("model_path", model.getTag("saved_model_path"))
except Exception:
pass
@ -690,18 +692,11 @@ class EditorPanelsRightMixin(
should_force_probe = False
if not (has_animation_tag or has_animation_nodes or has_cached_animation):
imgui.text_colored((0.7, 0.7, 0.7, 1.0), "此模型未检测到动画结构")
if self.app.style_manager.draw_toolbar_button(
"尝试强制检测",
size=(108, 26),
tooltip="重新扫描当前模型的动画结构",
):
should_force_probe = True
anim_node.setPythonTag("cached_anim_info", None)
anim_node.setPythonTag("cached_processed_names", None)
has_animation_nodes = self._get_cached_animation_structure_state(anim_node, force_refresh=True)
else:
return
should_force_probe = True
anim_node.setPythonTag("cached_anim_info", None)
anim_node.setPythonTag("cached_processed_names", None)
anim_node.setPythonTag("cached_has_animation_nodes", None)
has_animation_nodes = self._get_cached_animation_structure_state(anim_node, force_refresh=True)
# 只有在没有缓存时才进行完整的动画检测和处理
if cached_anim_info is None or cached_processed_names is None:
@ -711,7 +706,7 @@ class EditorPanelsRightMixin(
if has_animation_tag or has_animation_nodes:
imgui.text_colored((1.0, 0.7, 0.3, 1.0), "检测到动画结构但当前未成功绑定Actor")
elif should_force_probe:
imgui.text_colored((0.9, 0.6, 0.3, 1.0), "强制检测未发现可播放动画")
imgui.text_colored((0.7, 0.7, 0.7, 1.0), "此模型未检测到动画结构")
anim_node.setPythonTag("animation", False)
anim_node.setPythonTag("cached_processed_names", [])
anim_node.setPythonTag("cached_anim_info", "无动画")

View File

@ -123,8 +123,40 @@ class PanelDelegates:
# 检查是否为相机
if "camera" in node_name.lower() or "Camera" in node_name:
return "相机"
# 检查是否为模型
try:
if hasattr(self, "_resolve_animation_owner_model"):
owner = self._resolve_animation_owner_model(node)
if owner and not owner.isEmpty() and owner != node:
return "模型"
except Exception:
pass
try:
if hasattr(self, "animation_tools") and hasattr(self.animation_tools, "_resolve_ssbo_source_owner"):
source_owner = self.animation_tools._resolve_ssbo_source_owner(node)
if source_owner and not source_owner.isEmpty():
return "模型"
except Exception:
pass
try:
model_tag_names = ("model_path", "original_path", "saved_model_path")
if any(node.hasTag(tag_name) and bool(node.getTag(tag_name)) for tag_name in model_tag_names):
return "模型"
if node.hasTag("is_model_root") and node.getTag("is_model_root") == "1":
return "模型"
if node.hasTag("tree_item_type") and node.getTag("tree_item_type") == "IMPORTED_MODEL_NODE":
return "模型"
if node.hasTag("has_animations") and node.getTag("has_animations").lower() == "true":
return "模型"
cached_anim = node.getPythonTag("animation") if hasattr(node, "getPythonTag") else None
if cached_anim is True:
return "模型"
except Exception:
pass
if hasattr(self, 'scene_manager') and self.scene_manager:
if hasattr(self.scene_manager, 'models') and node in self.scene_manager.models:
return "模型"