EG/ui/panels/animation_tools.py

1573 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
from pathlib import Path
from direct.actor.Actor import Actor
from direct.task.TaskManagerGlobal import taskMgr
from panda3d.core import NodePath, PartSubset
class _BoundAnimationProxy:
"""Actor-compatible wrapper backed by auto-bound AnimControls."""
def __init__(self, node, controls, owns_node=False):
self._node = node
self._controls = controls
self._owns_node = owns_node
def isEmpty(self):
return self._node.isEmpty()
def show(self):
self._node.show()
def hide(self):
self._node.hide()
def setPos(self, *args):
self._node.setPos(*args)
def setHpr(self, *args):
self._node.setHpr(*args)
def setScale(self, *args):
self._node.setScale(*args)
def setTransform(self, *args):
self._node.setTransform(*args)
def getTransform(self, *args):
return self._node.getTransform(*args)
def reparentTo(self, *args):
self._node.reparentTo(*args)
def getAnimNames(self):
return list(self._controls.keys())
def getAnimControl(self, anim_name):
return self._controls.get(anim_name)
def play(self, anim_name):
control = self.getAnimControl(anim_name)
if control:
try:
control.stop()
except Exception:
pass
try:
control.pose(0)
except Exception:
pass
control.play()
def loop(self, anim_name):
control = self.getAnimControl(anim_name)
if control:
# AnimControl.loop 需要 restart 参数
try:
control.stop()
except Exception:
pass
try:
control.pose(0)
except Exception:
pass
control.loop(True)
def stop(self):
for control in self._controls.values():
try:
control.stop()
except Exception:
pass
def setPlayRate(self, speed, anim_name):
control = self.getAnimControl(anim_name)
if control:
control.setPlayRate(speed)
def cleanup(self):
if self._owns_node:
try:
if not self._node.isEmpty():
self._node.removeNode()
except Exception:
pass
def removeNode(self):
if self._owns_node:
try:
if not self._node.isEmpty():
self._node.removeNode()
except Exception:
pass
class AnimationTools:
"""Animation and actor helper methods extracted from main world."""
def __init__(self, app):
self.app = app
def __getattr__(self, name):
return getattr(self.app, name)
def __setattr__(self, name, value):
if name == "app" or name in self.__dict__ or hasattr(type(self), name):
object.__setattr__(self, name, value)
else:
setattr(self.app, name, value)
def _is_scene_root_node(self, node):
try:
if not node or node.isEmpty():
return False
name = node.getName()
return name in ("render", "render2d", "aspect2d", "pixel2d")
except Exception:
return False
def _get_owner_parent_node(self, owner_model):
"""获取动画对象的挂接父节点,确保不是空父节点。"""
try:
if owner_model and not owner_model.isEmpty():
parent = owner_model.getParent()
if parent and not parent.isEmpty():
return parent
except Exception:
pass
return self.render
def _node_has_geom(self, node):
try:
if not node or node.isEmpty():
return False
return node.findAllMatches("**/+GeomNode").getNumPaths() > 0
except Exception:
return False
def _node_has_animation_nodes(self, node):
try:
if not node or node.isEmpty():
return False
return (
node.findAllMatches("**/+Character").getNumPaths() > 0 or
node.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0
)
except Exception:
return False
def _prefer_owner_with_visible_geometry(self, origin_model, owner_model):
"""
优先选择“带可见几何体”的动画 owner避免绑定到纯骨骼节点导致播放时不可见。
"""
try:
if self._node_has_geom(owner_model):
return owner_model
candidates = []
def _add_candidate(node, bonus=0):
if not node or node.isEmpty() or self._is_scene_root_node(node):
return
for existing, _ in candidates:
try:
if existing == node:
return
except Exception:
continue
candidates.append((node, bonus))
_add_candidate(owner_model, 100)
_add_candidate(origin_model, 90)
# origin/owner 祖先链中优先找可见几何体节点
for seed, base_bonus in ((origin_model, 80), (owner_model, 70)):
current = seed
for depth in range(48):
if not current or current.isEmpty() or self._is_scene_root_node(current):
break
_add_candidate(current, base_bonus - depth)
parent = current.getParent()
if not parent or parent.isEmpty() or parent == current:
break
current = parent
# 从 scene_manager.models 中补充候选(含弱关联兜底)
scene_manager = getattr(self, "scene_manager", None)
models = getattr(scene_manager, "models", None) if scene_manager else None
if models:
origin_name = ""
try:
origin_name = (origin_model.getName() or "").strip().lower()
except Exception:
origin_name = ""
related_models = []
weak_name_match_models = []
anim_geom_models = []
for model in list(models):
try:
if not model or model.isEmpty() or self._is_scene_root_node(model):
continue
related = (
model == origin_model or
model == owner_model or
model.isAncestorOf(origin_model) or
origin_model.isAncestorOf(model) or
model.isAncestorOf(owner_model) or
owner_model.isAncestorOf(model)
)
if related:
related_models.append(model)
model_name = (model.getName() or "").strip().lower()
if origin_name and model_name == origin_name:
weak_name_match_models.append(model)
if self._node_has_geom(model) and self._node_has_animation_nodes(model):
anim_geom_models.append(model)
except Exception:
continue
for m in related_models:
_add_candidate(m, 75)
for m in weak_name_match_models:
_add_candidate(m, 60)
if len(anim_geom_models) == 1:
_add_candidate(anim_geom_models[0], 85)
best = owner_model
best_score = -10**9
for node, bonus in candidates:
try:
has_geom = self._node_has_geom(node)
has_anim = self._node_has_animation_nodes(node)
has_path = (
(node.hasTag("model_path") and bool(node.getTag("model_path"))) or
(node.hasTag("saved_model_path") and bool(node.getTag("saved_model_path"))) or
(node.hasTag("original_path") and bool(node.getTag("original_path")))
)
score = bonus
score += 220 if has_anim and has_geom else 0
score += 90 if has_geom else 0
score += 35 if has_anim else 0
score += 15 if has_path else 0
if score > best_score:
best_score = score
best = node
except Exception:
continue
# 把路径标签补齐到最终 owner避免后续回退到“格式未知/纯内存”
try:
if best and best != owner_model:
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
if best.hasTag(tag_name) and best.getTag(tag_name):
continue
if owner_model.hasTag(tag_name) and owner_model.getTag(tag_name):
best.setTag(tag_name, owner_model.getTag(tag_name))
except Exception:
pass
return best or owner_model
except Exception:
return owner_model
def _find_scene_model_owner(self, node):
"""优先从 scene_manager.models 反查真实模型根节点。"""
try:
scene_manager = getattr(self, "scene_manager", None)
models = getattr(scene_manager, "models", None)
if not models or not node or node.isEmpty():
return None
best = None
best_score = -1
related_found = False
for model in list(models):
try:
if not model or model.isEmpty() or self._is_scene_root_node(model):
continue
# 必须与当前节点存在祖先关系
relation = 0
if model == node:
relation = 3
elif model.isAncestorOf(node):
relation = 2
elif node.isAncestorOf(model):
relation = 1
if relation == 0:
continue
related_found = True
has_model_path = model.hasTag("model_path") and bool(model.getTag("model_path"))
has_saved_path = model.hasTag("saved_model_path") and bool(model.getTag("saved_model_path"))
has_anim = (
model.findAllMatches("**/+Character").getNumPaths() > 0 or
model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0
)
has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0
is_model_root = model.hasTag("is_model_root") and model.getTag("is_model_root") == "1"
is_imported_root = model.hasTag("tree_item_type") and model.getTag("tree_item_type") == "IMPORTED_MODEL_NODE"
score = 0
score += relation * 100
score += 60 if has_model_path else 0
score += 40 if has_saved_path else 0
score += 30 if has_anim else 0
score += 20 if has_geom else 0
score += 10 if is_model_root else 0
score += 8 if is_imported_root else 0
if score > best_score:
best_score = score
best = model
except Exception:
continue
if best:
return best
# 关系反查失败时的兜底:同名优先(常见于场景重建后层级丢失)
node_name = ""
try:
node_name = (node.getName() or "").strip().lower()
except Exception:
node_name = ""
if not related_found and node_name:
fallback_best = None
fallback_score = -1
for model in list(models):
try:
if not model or model.isEmpty() or self._is_scene_root_node(model):
continue
model_name = (model.getName() or "").strip().lower()
if model_name != node_name:
continue
has_model_path = model.hasTag("model_path") and bool(model.getTag("model_path"))
has_saved_path = model.hasTag("saved_model_path") and bool(model.getTag("saved_model_path"))
has_anim = (
model.findAllMatches("**/+Character").getNumPaths() > 0 or
model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0
)
has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0
score = 0
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
if score > fallback_score:
fallback_score = score
fallback_best = model
except Exception:
continue
if fallback_best:
return fallback_best
# 单模型场景兜底
valid_models = []
for model in list(models):
try:
if model and (not model.isEmpty()) and (not self._is_scene_root_node(model)):
valid_models.append(model)
except Exception:
continue
if len(valid_models) == 1:
return valid_models[0]
# 若场景中仅有一个“动画+几何体”模型,也直接使用它
anim_models = []
for model in valid_models:
try:
has_anim = (
model.findAllMatches("**/+Character").getNumPaths() > 0 or
model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0
)
has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0
if has_anim and has_geom:
anim_models.append(model)
except Exception:
continue
if len(anim_models) == 1:
return anim_models[0]
return None
except Exception:
return None
def _recover_model_path_from_tags(self, node):
"""从常见标签恢复模型路径,尽量避免退化到纯内存 autoBind。"""
try:
project_root = Path(__file__).resolve().parents[2]
search_roots = [
project_root,
project_root / "Resources",
project_root / "Resources" / "models",
]
raw_candidates = []
if node and not node.isEmpty():
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
try:
if node.hasTag(tag_name):
val = node.getTag(tag_name)
if val:
raw_candidates.append(val)
except Exception:
continue
# 父链继续补齐
walker = node.getParent() if (node and not node.isEmpty()) else None
for _ in range(64):
if not walker or walker.isEmpty() or self._is_scene_root_node(walker):
break
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
try:
if walker.hasTag(tag_name):
val = walker.getTag(tag_name)
if val:
raw_candidates.append(val)
except Exception:
continue
parent = walker.getParent()
if not parent or parent.isEmpty() or parent == walker:
break
walker = parent
# scene_manager.models 补齐
model_owner = self._find_scene_model_owner(node)
if model_owner and not model_owner.isEmpty():
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
try:
if model_owner.hasTag(tag_name):
val = model_owner.getTag(tag_name)
if val:
raw_candidates.append(val)
except Exception:
continue
# 全局兜底:当当前节点标签全丢失时,从 scene_manager.models 收集唯一可用路径
scene_manager = getattr(self, "scene_manager", None)
models = getattr(scene_manager, "models", None) if scene_manager else None
if models:
scene_paths = []
best_related_path = ""
best_related_score = -1
for model in list(models):
try:
if not model or model.isEmpty() or self._is_scene_root_node(model):
continue
relation = 0
try:
if model == node:
relation = 3
elif model.isAncestorOf(node):
relation = 2
elif node.isAncestorOf(model):
relation = 1
except Exception:
relation = 0
has_anim = self._node_has_animation_nodes(model)
has_geom = self._node_has_geom(model)
candidate_path = ""
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
if model.hasTag(tag_name):
val = model.getTag(tag_name)
if val:
scene_paths.append(val)
if (not candidate_path) and tag_name in ("model_path", "original_path", "saved_model_path"):
candidate_path = val
if relation > 0 and candidate_path:
score = relation * 100
score += 40 if has_anim else 0
score += 25 if has_geom else 0
try:
if os.path.exists(candidate_path) or os.path.exists(os.path.normpath(candidate_path)):
score += 35
except Exception:
pass
if score > best_related_score:
best_related_score = score
best_related_path = candidate_path
except Exception:
continue
# 去重
uniq_scene_paths = []
seen_scene = set()
for p in scene_paths:
try:
key = os.path.normcase(os.path.normpath(p))
except Exception:
key = p
if key in seen_scene:
continue
seen_scene.add(key)
uniq_scene_paths.append(p)
# 仅在唯一时采用,避免误绑到别的模型
if len(uniq_scene_paths) == 1:
raw_candidates.append(uniq_scene_paths[0])
elif best_related_path:
raw_candidates.append(best_related_path)
# 1) 直接存在的路径优先
for c in raw_candidates:
try:
if os.path.exists(c):
return c
norm = os.path.normpath(c)
if os.path.exists(norm):
return norm
except Exception:
continue
# 2) 基于文件名在项目目录中补查
filenames = []
for c in raw_candidates:
try:
name = os.path.basename(c)
if name and "." in name:
filenames.append(name)
except Exception:
continue
tried = set()
for filename in filenames:
for root in search_roots:
candidate = str(root / filename)
key = os.path.normcase(os.path.normpath(candidate))
if key in tried:
continue
tried.add(key)
if os.path.exists(candidate):
return candidate
return ""
except Exception:
return ""
def _resolve_animation_owner_model(self, node):
"""向上查找动画所属的模型根节点,避免选中子节点时绑定失败。"""
scene_owner = self._find_scene_model_owner(node)
if scene_owner and not scene_owner.isEmpty():
try:
# 同步路径标签,避免后续格式识别为“未知”
if scene_owner.hasTag("model_path") and scene_owner.getTag("model_path"):
if (not node.hasTag("model_path")) or (not node.getTag("model_path")):
node.setTag("model_path", scene_owner.getTag("model_path"))
if scene_owner.hasTag("original_path") and scene_owner.getTag("original_path"):
if (not node.hasTag("original_path")) or (not node.getTag("original_path")):
node.setTag("original_path", scene_owner.getTag("original_path"))
except Exception:
pass
return scene_owner
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"))
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 += 100 if has_anim and has_geom else 0
score += 60 if has_model_path and has_geom else 0
score += 40 if has_anim else 0
score += 20 if has_geom else 0
score += 15 if has_model_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
return best
except Exception:
pass
try:
if node and not node.isEmpty() and not self._is_scene_root_node(node):
return node
except Exception:
pass
return node
def _getActor(self, origin_model):
"""
获取或创建模型的Actor用于动画控制
复用Qt版本经过验证的实现方式
"""
owner_model = self._resolve_animation_owner_model(origin_model)
owner_model = self._prefer_owner_with_visible_geometry(origin_model, owner_model)
# owner 节点缺失路径标签时,尝试从当前节点及其祖先继承路径,避免只能走内存分支
try:
needs_path = (not owner_model.hasTag("model_path")) or (not owner_model.getTag("model_path"))
if needs_path:
walker = origin_model
for _ in range(64):
if not walker or walker.isEmpty() or self._is_scene_root_node(walker):
break
if walker.hasTag("model_path") and walker.getTag("model_path"):
owner_model.setTag("model_path", walker.getTag("model_path"))
if walker.hasTag("original_path") and walker.getTag("original_path"):
owner_model.setTag("original_path", walker.getTag("original_path"))
break
if walker.hasTag("saved_model_path") and walker.getTag("saved_model_path"):
owner_model.setTag("model_path", walker.getTag("saved_model_path"))
break
parent = walker.getParent()
if not parent or parent.isEmpty() or parent == walker:
break
walker = parent
# 仍缺路径时,尝试更激进的标签恢复
if (not owner_model.hasTag("model_path")) or (not owner_model.getTag("model_path")):
recovered = self._recover_model_path_from_tags(owner_model)
if recovered:
owner_model.setTag("model_path", recovered)
if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")):
owner_model.setTag("original_path", recovered)
except Exception:
pass
def _cleanup_actor(actor):
try:
if actor is None:
return
if hasattr(actor, "cleanup"):
actor.cleanup()
if hasattr(actor, "removeNode"):
actor.removeNode()
except Exception:
pass
def _try_create_actor_from_source(source, source_desc):
try:
actor = Actor(source)
# 无论是否已检测到动画名,都显式绑定一次,避免“有名字但无可播放控制”
try:
actor.bindAllAnims(allowAsyncBind=False)
except Exception:
pass
anims = actor.getAnimNames()
print(f"[Actor加载] {source_desc} 检测到动画: {anims}")
if not anims:
_cleanup_actor(actor)
return None
# 确认至少有一个可用的 AnimControl
playable = False
for anim_name in anims:
try:
control = actor.getAnimControl(anim_name)
if control and control.getNumFrames() > 1:
playable = True
break
except Exception:
continue
if not playable:
print(f"[Actor加载] {source_desc} 动画控制无效,尝试其他加载路径")
_cleanup_actor(actor)
return None
# 无可见几何体的 Actor 会导致“播放后什么都看不到”
has_geom = False
try:
has_geom = actor.findAllMatches("**/+GeomNode").getNumPaths() > 0
except Exception:
has_geom = False
if not has_geom:
print(f"[Actor加载] {source_desc} 无可见几何体,尝试其他加载路径")
_cleanup_actor(actor)
return None
actor.reparentTo(self._get_owner_parent_node(owner_model))
actor.hide()
return actor
except Exception as e:
print(f"[Actor加载] {source_desc} 失败: {e}")
return None
def _try_create_autobind_proxy(model_np, source_desc, owns_node=False):
try:
from panda3d.core import AnimControlCollection, autoBind
controls = {}
def collect_controls(np):
if not np or np.isEmpty():
return
try:
acc = AnimControlCollection()
autoBind(np.node(), acc, ~0)
for i in range(acc.getNumAnims()):
name = acc.getAnimName(i) or f"anim_{i}"
control = acc.getAnim(i)
if not control:
continue
if name in controls:
try:
old_frames = controls[name].getNumFrames()
except Exception:
old_frames = -1
try:
new_frames = control.getNumFrames()
except Exception:
new_frames = -1
# 重名时保留帧数更多的控制,避免默认选到“看起来无效”的同名动画
if new_frames >= old_frames:
controls[name] = control
else:
controls[name] = control
except Exception:
pass
# 先在根节点尝试,再补扫 Character 节点
collect_controls(model_np)
character_nodes = model_np.findAllMatches("**/+Character")
for i in range(character_nodes.getNumPaths()):
collect_controls(character_nodes.getPath(i))
# autoBind 无结果时,尝试手动把 AnimBundle 绑定到 Character 的 PartBundle
if not controls and character_nodes.getNumPaths() > 0:
anim_bundle_nodes = model_np.findAllMatches("**/+AnimBundleNode")
subset = PartSubset()
for ci in range(character_nodes.getNumPaths()):
try:
character = character_nodes.getPath(ci).node()
for bi in range(character.getNumBundles()):
part_bundle = character.getBundle(bi)
for ai in range(anim_bundle_nodes.getNumPaths()):
try:
anim_bundle_node = anim_bundle_nodes.getPath(ai).node()
anim_bundle = anim_bundle_node.getBundle()
if not anim_bundle:
continue
anim_name = anim_bundle.getName() or anim_bundle_node.getName() or f"anim_{ai}"
control = part_bundle.bindAnim(anim_bundle, -1, subset)
if not control:
continue
if anim_name in controls:
try:
old_frames = controls[anim_name].getNumFrames()
except Exception:
old_frames = -1
try:
new_frames = control.getNumFrames()
except Exception:
new_frames = -1
if new_frames >= old_frames:
controls[anim_name] = control
else:
controls[anim_name] = control
except Exception:
continue
except Exception:
continue
if not controls:
return None
# 仅骨骼无几何体时会出现“能触发播放但场景不可见”
try:
has_geom = model_np.findAllMatches("**/+GeomNode").getNumPaths() > 0
except Exception:
has_geom = False
if not has_geom:
print(f"[Actor加载] {source_desc} autoBind 检测到动画但无可见几何体,尝试其他节点")
return None
print(f"[Actor加载] {source_desc} autoBind 检测到动画: {list(controls.keys())}")
return _BoundAnimationProxy(model_np, controls, owns_node=owns_node)
except Exception as e:
print(f"[Actor加载] {source_desc} autoBind 失败: {e}")
return None
def _try_create_actor_via_gltf_path(path):
"""针对部分 GLB/GLTF 文件,使用 gltf 插件强制加载动画后再创建 Actor。"""
lower_path = str(path).lower()
if not (lower_path.endswith(".glb") or lower_path.endswith(".gltf")):
return None
model_np = None
succeeded = False
try:
import gltf
from panda3d.core import Filename
panda_path = Filename.from_os_specific(path).get_fullpath()
os_path = Filename(panda_path).to_os_specific()
settings = gltf.GltfSettings(skip_animations=False)
model_root = gltf.load_model(os_path, settings)
if not model_root:
return None
model_np = NodePath(model_root)
actor = _try_create_actor_from_source(model_np, f"GLTF专用加载({path})")
if actor:
succeeded = True
return actor
proxy = _try_create_autobind_proxy(model_np, f"GLTF专用加载({path})", owns_node=True)
if proxy:
proxy.reparentTo(self._get_owner_parent_node(owner_model))
proxy.hide()
succeeded = True
return proxy
except Exception as e:
print(f"[Actor加载] GLTF专用加载失败 ({path}): {e}")
finally:
# 仅在失败且本地临时节点仍存在时清理,避免泄漏
try:
if (not succeeded) and model_np and not model_np.isEmpty():
model_np.removeNode()
except Exception:
pass
return None
# 检查缓存(无效缓存自动清理,避免“无动画”结果被永久缓存)
cached_actor = self._actor_cache.get(owner_model)
if cached_actor:
# 若已获得模型路径,优先重建真实 Actor避免长期停留在 autoBind 代理导致“能触发但不可见”
try:
owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path"))
except Exception:
owner_has_path = False
if owner_has_path and isinstance(cached_actor, _BoundAnimationProxy):
_cleanup_actor(cached_actor)
self._actor_cache.pop(owner_model, None)
cached_actor = None
if cached_actor:
try:
has_geom = False
if isinstance(cached_actor, _BoundAnimationProxy):
has_geom = self._node_has_geom(getattr(cached_actor, "_node", None))
else:
try:
has_geom = cached_actor.findAllMatches("**/+GeomNode").getNumPaths() > 0
except Exception:
has_geom = False
if (not cached_actor.isEmpty()) and cached_actor.getAnimNames() and has_geom:
return cached_actor
except Exception:
pass
_cleanup_actor(cached_actor)
self._actor_cache.pop(owner_model, None)
# 先检测模型树中的动画结构,并写入标签供属性面板快速判断
try:
character_nodes = owner_model.findAllMatches("**/+Character")
anim_bundle_nodes = owner_model.findAllMatches("**/+AnimBundleNode")
has_animation_nodes = (
character_nodes.getNumPaths() > 0 or
anim_bundle_nodes.getNumPaths() > 0
)
owner_model.setTag("has_animations", "true" if has_animation_nodes else "false")
if has_animation_nodes:
owner_model.setTag("can_create_actor_from_memory", "true")
except Exception:
has_animation_nodes = False
def _try_memory_fallback():
def _collect_autobind_source_candidates():
candidates = []
def add_candidate(node):
try:
if not node or node.isEmpty() or self._is_scene_root_node(node):
return
for existing in candidates:
if existing == node:
return
candidates.append(node)
except Exception:
return
add_candidate(owner_model)
add_candidate(origin_model)
add_candidate(self._prefer_owner_with_visible_geometry(origin_model, owner_model))
add_candidate(self._find_scene_model_owner(origin_model))
add_candidate(self._find_scene_model_owner(owner_model))
# 祖先链补齐
for seed in (origin_model, owner_model):
current = seed
for _ in range(48):
if not current or current.isEmpty() or self._is_scene_root_node(current):
break
add_candidate(current)
parent = current.getParent()
if not parent or parent.isEmpty() or parent == current:
break
current = parent
# scene_manager.models 补齐候选
scene_manager = getattr(self, "scene_manager", None)
models = getattr(scene_manager, "models", None) if scene_manager else None
if models:
for model in list(models):
add_candidate(model)
def score(node):
try:
has_geom = self._node_has_geom(node)
has_anim = self._node_has_animation_nodes(node)
has_path = (
(node.hasTag("model_path") and bool(node.getTag("model_path"))) or
(node.hasTag("saved_model_path") and bool(node.getTag("saved_model_path"))) or
(node.hasTag("original_path") and bool(node.getTag("original_path")))
)
related = False
try:
related = (
node == owner_model or
node == origin_model or
node.isAncestorOf(origin_model) or
origin_model.isAncestorOf(node) or
node.isAncestorOf(owner_model) or
owner_model.isAncestorOf(node)
)
except Exception:
related = False
s = 0
s += 220 if has_geom and has_anim else 0
s += 120 if has_geom else 0
s += 45 if has_anim else 0
s += 35 if has_path else 0
s += 40 if node == owner_model else 0
s += 30 if node == origin_model else 0
s += 25 if related else 0
return s
except Exception:
return -1
candidates.sort(key=score, reverse=True)
return candidates
can_create_from_memory = False
if owner_model.hasTag("can_create_actor_from_memory"):
can_create_from_memory = owner_model.getTag("can_create_actor_from_memory").lower() == "true"
if not can_create_from_memory and has_animation_nodes:
can_create_from_memory = True
if can_create_from_memory:
# 不能直接 Actor(owner_model);会污染当前场景节点,导致播放后模型消失/选择失效。
# 先用副本创建真实 Actor只有失败时才退回 autoBind 代理。
clone_parent = self._get_owner_parent_node(owner_model)
clone_np = None
try:
clone_np = owner_model.copyTo(clone_parent)
clone_np.setName(f"{owner_model.getName()}__anim_runtime")
mem_actor = _try_create_actor_from_source(clone_np, "内存模型副本")
if mem_actor:
self._actor_cache[owner_model] = mem_actor
owner_model.setTag("has_animations", "true")
return mem_actor
except Exception as e:
print(f"[Actor加载] 创建内存模型副本失败: {e}")
finally:
# _try_create_actor_from_source 失败时,清理临时副本
try:
if clone_np and not clone_np.isEmpty() and owner_model not in self._actor_cache:
clone_np.removeNode()
except Exception:
pass
# Actor 副本失败后,从多个候选节点中选择“带几何体”的 autoBind 源
for source_node in _collect_autobind_source_candidates():
mem_proxy = _try_create_autobind_proxy(
source_node,
f"内存模型({source_node.getName()})",
owns_node=False
)
if mem_proxy:
self._actor_cache[owner_model] = mem_proxy
owner_model.setTag("has_animations", "true")
if source_node != owner_model:
print(f"[Actor加载] 使用可见节点作为动画源: {owner_model.getName()} -> {source_node.getName()}")
return mem_proxy
return None
# 始终优先尝试从文件路径加载,因为底层 gltf 插件只有在加载文件时才能抽取完整的动画名称。
print(f"[Actor加载调试] 传入的 origin_model: {origin_model.getName() if origin_model else 'None'}")
print(f"[Actor加载调试] origin_model tags: {origin_model.getTags() if origin_model else 'None'}")
print(f"[Actor加载调试] 解析出的 owner_model: {owner_model.getName() if owner_model else 'None'}")
try:
print(f"[Actor加载调试] owner_model tags: {owner_model.getTags() if owner_model else 'None'}")
print(f"[Actor加载调试] owner_model path tag: {owner_model.getTag('model_path') if owner_model.hasTag('model_path') else 'MISSING'}")
except Exception as e:
print(f"[Actor加载调试] 获取 tags 异常: {e}")
filepath = owner_model.getTag("model_path") if owner_model.hasTag("model_path") else ""
if not filepath and owner_model.hasTag("original_path"):
filepath = owner_model.getTag("original_path")
print(f"[Actor加载调试] 获取到的 filepath: '{filepath}'")
if not filepath:
print(f"[Actor加载调试] filepath为空触发 _try_memory_fallback()")
return _try_memory_fallback()
# 针对Actor加载必须使用Panda3D规范的Unix风格路径否则Windows绝对路径会导致加载彻底崩溃并返回空节点
panda_specific_path = ""
try:
from panda3d.core import Filename
panda_specific_path = Filename.from_os_specific(filepath).get_fullpath()
except:
panda_specific_path = filepath.replace('\\', '/')
candidate_paths = [panda_specific_path, filepath, os.path.normpath(filepath)]
# 处理 /d/... 这类路径在 Windows 上无法直接访问的问题 (补充兜底OS路径)
if filepath.startswith("/") and os.name == "nt":
parts = filepath.split("/")
if len(parts) > 2 and len(parts[1]) == 1:
win_path = f"{parts[1].upper()}:\\{os.path.join(*parts[2:])}" if parts[2:] else f"{parts[1].upper()}:\\"
candidate_paths.append(win_path)
candidate_paths.append(os.path.normpath(win_path))
# 尝试 Panda3D 路径标准化
try:
from scene import util
candidate_paths.append(util.normalize_model_path(filepath))
except Exception:
pass
# 在 Resources/models 中按文件名兜底查找
filename = os.path.basename(filepath)
if filename:
candidate_paths.append(str(Path(__file__).resolve().parents[2] / "Resources" / "models" / filename))
candidate_paths.append(str(Path(__file__).resolve().parents[2] / "Resources" / filename))
# 去重并优先使用真实存在的路径 (同时确保panda专属路径排在第一位尝试)
unique_paths = []
seen = set()
for p in candidate_paths:
if not p:
continue
key = os.path.normcase(os.path.normpath(p))
if key in seen:
continue
seen.add(key)
unique_paths.append(p)
# 过滤时注意如果是以 / 开头的 Panda 路径os.path.exists 可能判断为 False所以要额外豁免 panda_specific_path 保底加载
existing_paths = [p for p in unique_paths if os.path.exists(p) or p == panda_specific_path]
load_paths = existing_paths + [p for p in unique_paths if p not in existing_paths]
for p in load_paths:
print(f"[Actor加载验证] 正在尝试通过路径读取骨骼和动画文件: {p}")
actor = _try_create_actor_from_source(p, f"文件路径({p})")
if actor:
owner_model.setTag("model_path", p)
owner_model.setTag("has_animations", "true")
self._actor_cache[owner_model] = actor
return actor
# 标准 Actor 路径失败时,针对 GLTF/GLB 走插件加载兜底
actor = _try_create_actor_via_gltf_path(p)
if actor:
owner_model.setTag("model_path", p)
owner_model.setTag("has_animations", "true")
self._actor_cache[owner_model] = actor
return actor
# 路径 Actor 失败后,再尝试把文件作为普通模型加载并 autoBind
try:
loaded_model = self.loader.loadModel(p)
if loaded_model and not loaded_model.isEmpty():
proxy = _try_create_autobind_proxy(loaded_model, f"文件路径({p})", owns_node=True)
if proxy:
loaded_model.reparentTo(self.render)
loaded_model.hide()
owner_model.setTag("model_path", p)
owner_model.setTag("has_animations", "true")
self._actor_cache[owner_model] = proxy
return proxy
loaded_model.removeNode()
except Exception:
pass
# 所有创建路径失败时由内存加载进行兜底
return _try_memory_fallback()
def _getModelFormat(self, origin_model):
"""获取模型格式信息"""
filepath = origin_model.getTag("model_path") if origin_model.hasTag("model_path") else ""
if not filepath and origin_model.hasTag("original_path"):
filepath = origin_model.getTag("original_path")
if not filepath and origin_model.hasTag("saved_model_path"):
filepath = origin_model.getTag("saved_model_path")
if not filepath and origin_model.hasTag("file"):
filepath = origin_model.getTag("file")
original_path = origin_model.getTag("original_path")
converted_from = origin_model.getTag("converted_from")
if filepath:
ext = filepath.lower().split('.')[-1]
format_name = ext.upper()
# 如果是转换后的文件,显示转换信息
if converted_from and original_path:
original_ext = converted_from.upper()
format_name = f"{format_name} (从{original_ext}转换)"
return format_name
return "未知"
def _processAnimationNames(self, origin_model, anim_names):
"""处理和分析动画名称,返回 [(显示名称, 原始名称), ...]"""
format_info = self._getModelFormat(origin_model)
processed = []
print(f"[动画分析] 格式: {format_info}, 原始动画名称: {anim_names}")
for name in anim_names:
display_name = name
original_name = name
format_upper = format_info.upper()
if format_upper.startswith("GLB") or format_upper.startswith("GLTF"):
# GLB 格式通常有真实的动画名称
if "|" in name:
# 处理类似 'Armature|mixamo.com|Layer0' 的名称
parts = name.split("|")
if "mixamo" in name.lower():
# Mixamo 动画
display_name = f"Mixamo_{parts[-1]}" if len(parts) > 1 else name
elif len(parts) > 2:
# 其他复杂命名
display_name = f"{parts[0]}_{parts[-1]}"
else:
display_name = parts[-1]
elif ":" in name:
# 一些导入器会产生前缀:动作名
display_name = name.split(":")[-1]
elif "/" in name:
display_name = name.split("/")[-1]
elif format_upper.startswith("FBX"):
# FBX 格式可能需要特殊处理
if self._isLikelyBoneGroup(name):
# 检查是否是骨骼组而非动画
print(f"[警告] '{name}' 可能不是真正的动画序列,而是骨骼组")
display_name = f"⚠️ {name} (可能非动画)"
else:
display_name = name
elif format_upper.startswith("EGG") or format_upper.startswith("BAM"):
# 原生格式通常命名规范
display_name = name
processed.append((display_name, original_name))
print(f"[动画分析] {original_name}{display_name}")
return processed
def _isLikelyBoneGroup(self, name):
"""判断动画名称是否更像骨骼组而不是动画序列"""
bone_indicators = ['joints', 'bones', 'skeleton', 'surface', 'mesh', 'beta', 'rig']
name_lower = name.lower()
# 如果包含这些关键词,可能是骨骼组
for indicator in bone_indicators:
if indicator in name_lower:
return True
# 如果名称太简单少于3个字符可能不是动画
if len(name) < 3:
return True
return False
def _analyzeAnimationQuality(self, actor, anim_names, format_info):
"""分析动画质量和类型(优化版本,减少详细分析以提高性能)"""
try:
valid_anims = 0
# 简化分析:只检查动画是否存在,不详细分析帧数
for anim_name in anim_names:
try:
control = actor.getAnimControl(anim_name)
if control and control.getNumFrames() > 1:
valid_anims += 1
except Exception:
# 忽略单个动画的分析错误,继续处理其他动画
continue
if valid_anims == 0:
return "⚠️ 无有效动画"
elif valid_anims < len(anim_names):
return f"⚠️ {valid_anims}/{len(anim_names)} 个有效"
else:
return f"{valid_anims} 个动画"
except Exception as e:
# 简化错误处理
return "分析异常"
def _resolve_selected_animation_name(self, origin_model, actor, owner_model=None):
"""返回可播放的动画名;当当前选择无效时自动回退到首个动画并回写标签。"""
try:
anim_names = actor.getAnimNames() if actor else []
except Exception:
anim_names = []
if not anim_names:
return None
current_anim = origin_model.getPythonTag("selected_animation")
if owner_model and owner_model != origin_model and current_anim not in anim_names:
current_anim = owner_model.getPythonTag("selected_animation")
if current_anim not in anim_names:
current_anim = anim_names[0]
try:
origin_model.setPythonTag("selected_animation", current_anim)
if owner_model and owner_model != origin_model:
owner_model.setPythonTag("selected_animation", current_anim)
except Exception:
pass
return current_anim
def _should_swap_visibility_for_actor(self, owner_model, actor):
"""
是否需要“隐藏原模型/显示Actor”的切换。
当 actor 直接绑定到 owner_model 本体时,不应切换可见性,否则会导致选中节点瞬间丢失。
"""
if self._is_scene_root_node(owner_model):
# 绝不隐藏场景根节点
return False
try:
if isinstance(actor, _BoundAnimationProxy):
# 非 owns_node 代理直接绑定场景节点,不能做“隐藏原模型/显示Actor”切换
if not getattr(actor, "_owns_node", False):
return False
return getattr(actor, "_node", None) != owner_model
except Exception:
pass
try:
return actor != owner_model
except Exception:
return True
def _playAnimation(self, origin_model):
"""播放动画"""
try:
if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden():
self.render.show()
except Exception:
pass
owner_model = self._resolve_animation_owner_model(origin_model)
actor = self._getActor(owner_model)
if not actor:
return
# 若仍在“无路径的内存代理”,播放前强制再尝试一次路径恢复并重建真实 Actor
if isinstance(actor, _BoundAnimationProxy):
owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path"))
if not owner_has_path:
recovered_path = self._recover_model_path_from_tags(owner_model) or self._recover_model_path_from_tags(origin_model)
if recovered_path:
try:
owner_model.setTag("model_path", recovered_path)
if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")):
owner_model.setTag("original_path", recovered_path)
except Exception:
pass
self._clear_animation_cache(owner_model)
actor = self._getActor(owner_model)
if not actor:
return
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(owner_model.getPos())
actor.setHpr(owner_model.getHpr())
actor.setScale(owner_model.getScale())
if self._should_swap_visibility_for_actor(owner_model, actor):
owner_model.hide()
else:
owner_model.show()
actor.show()
task_name = f"maintain_anim_pos_{id(actor)}"
# 维持 Actor 的世界变换,避免父节点层级差异导致位置抖动/漂移
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)
return task.cont
else:
return task.done
except:
return task.done
taskMgr.remove(task_name)
if not is_scene_bound_proxy:
taskMgr.add(maintainWorldPosition, task_name)
# 获取当前选中的动画
current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model)
if current_anim:
try:
actor.stop()
except Exception:
pass
actor.play(current_anim)
print(f"『动画播放』:{current_anim}")
def _pauseAnimation(self, origin_model):
"""暂停动画"""
owner_model = self._resolve_animation_owner_model(origin_model)
actor = self._getActor(owner_model)
if not actor:
return
if isinstance(actor, _BoundAnimationProxy):
owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path"))
if not owner_has_path:
recovered_path = self._recover_model_path_from_tags(owner_model) or self._recover_model_path_from_tags(origin_model)
if recovered_path:
try:
owner_model.setTag("model_path", recovered_path)
if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")):
owner_model.setTag("original_path", recovered_path)
except Exception:
pass
self._clear_animation_cache(owner_model)
actor = self._getActor(owner_model)
if not actor:
return
is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False))
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))
# 隐藏原始模型显示Actor
if self._should_swap_visibility_for_actor(owner_model, actor):
owner_model.hide()
else:
owner_model.show()
actor.show()
# 停止动画(保持当前姿势)
actor.stop()
print("『动画』暂停")
def _stopAnimation(self, origin_model):
"""停止动画"""
try:
if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden():
self.render.show()
except Exception:
pass
owner_model = self._resolve_animation_owner_model(origin_model)
actor = self._getActor(owner_model)
if not actor:
return
# 停止动画
actor.stop()
# 获取当前选中的动画
current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model)
if current_anim and actor.getAnimControl(current_anim):
actor.getAnimControl(current_anim).pose(0)
# 隐藏Actor显示原始模型
if self._should_swap_visibility_for_actor(owner_model, actor):
actor.hide()
owner_model.show()
# 移除位置维护任务
taskMgr.remove(f"maintain_anim_pos_{id(actor)}")
print("『动画』停止切换至原始模型")
def _loopAnimation(self, origin_model):
"""循环播放动画"""
try:
if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden():
self.render.show()
except Exception:
pass
owner_model = self._resolve_animation_owner_model(origin_model)
actor = self._getActor(owner_model)
if not actor:
return
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(owner_model.getPos())
actor.setHpr(owner_model.getHpr())
actor.setScale(owner_model.getScale())
if self._should_swap_visibility_for_actor(owner_model, actor):
owner_model.hide()
else:
owner_model.show()
actor.show()
task_name = f"maintain_anim_pos_{id(actor)}"
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)
return task.cont
else:
return task.done
except:
return task.done
taskMgr.remove(task_name)
if not is_scene_bound_proxy:
taskMgr.add(maintainWorldPosition, task_name)
# 获取当前选中的动画
current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model)
if current_anim:
try:
actor.stop()
except Exception:
pass
actor.loop(current_anim)
print(f"[动画] 循环: {current_anim}")
def _setAnimationSpeed(self, origin_model, speed):
"""设置动画播放速度"""
owner_model = self._resolve_animation_owner_model(origin_model)
actor = self._getActor(owner_model)
if not actor:
return
# 获取当前选中的动画
current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model)
if current_anim:
actor.setPlayRate(speed, current_anim)
print(f"[动画] 速度设为: {speed} ({current_anim})")
else:
# 兜底:尝试所有动画
anim_names = actor.getAnimNames()
for anim_name in anim_names:
actor.setPlayRate(speed, anim_name)
print(f"[动画] 速度设为: {speed} (所有动画)")
def _clear_animation_cache(self, node):
"""清除节点的动画缓存,当模型发生变化时调用"""
owner_node = self._resolve_animation_owner_model(node)
node.setPythonTag("cached_anim_info", None)
node.setPythonTag("cached_processed_names", None)
node.setPythonTag("animation", None) # 同时清除动画检测结果
# 如果Actor在缓存中也需要清理
if owner_node in self._actor_cache:
actor = self._actor_cache[owner_node]
try:
# 清理相关任务
taskMgr.remove(f"maintain_anim_pos_{id(actor)}")
# 清理Actor
if not actor.isEmpty():
actor.cleanup()
actor.removeNode()
except Exception as e:
print(f"清理Actor缓存失败: {e}")
finally:
del self._actor_cache[owner_node]
print(f"[缓存清理] 清除节点 {owner_node.getName()} 的动画缓存")