优化导入速度

This commit is contained in:
Hector 2026-04-01 10:46:05 +08:00
parent f9f060b1ac
commit 78ffa8efba
11 changed files with 294 additions and 67 deletions

View File

@ -413,6 +413,44 @@ class EventHandler:
or lowered.startswith("gizmo")
)
def _resolve_model_root(node):
current = node
model_list = self.world.models if hasattr(self.world, "models") else []
while _is_valid_node(current) and current != self.world.render:
try:
if current in model_list or current.hasTag("is_model_root"):
return current
current = current.getParent()
except Exception:
break
return None
# In SSBO mode, animated/legacy models still rely on modelCollision_* for picking,
# so we cannot ignore those hits unconditionally.
# Only treat a hit on the *currently selected same model root* as blank-space clear.
if getattr(self.world, "use_ssbo_mouse_picking", False):
try:
hit_name = hitNode.getName() or ""
except Exception:
hit_name = ""
if hit_name.lower().startswith("modelcollision_"):
current_selected = None
try:
current_selected = self.world.selection.getSelectedNode()
except Exception:
current_selected = getattr(getattr(self, "world", None), "selection", None)
owner_root = _resolve_model_root(hitNode)
try:
same_root = bool(
current_selected and owner_root and current_selected == owner_root
)
except Exception:
same_root = False
if same_root:
print("SSBO 模式下命中当前选中模型的辅助碰撞壳,按空白区域清除选择")
self.world.selection.updateSelection(None)
return
def _is_selectable_scene_node(node):
if not _is_valid_node(node) or node == self.world.render:
return False

View File

@ -78,7 +78,7 @@ Size=93,65
Collapsed=0
[Window][新建项目]
Pos=760,366
Pos=760,354
Size=400,300
Collapsed=0
@ -206,7 +206,7 @@ Collapsed=0
DockId=0x00000005,2
[Window][项目另存为]
Pos=730,396
Pos=730,384
Size=460,240
Collapsed=0

View File

@ -87,9 +87,6 @@ class AssetDatabase:
}
)
if asset_type == "model":
self._build_model_import_cache(record)
assets[asset_guid] = record
if previous_asset_path != relative_asset_path:
changed = True
@ -290,7 +287,13 @@ class AssetDatabase:
"import_info": relative_project_path(self.layout.project_root, import_info_path),
}
def register_asset(self, asset_path: str, preferred_subdir: str = "", copy_into_assets: bool = False) -> dict:
def register_asset(
self,
asset_path: str,
preferred_subdir: str = "",
copy_into_assets: bool = False,
build_import_cache: bool = False,
) -> dict:
asset_path = normalize_path(asset_path)
if not os.path.exists(asset_path):
return {}
@ -323,7 +326,7 @@ class AssetDatabase:
}
)
if asset_type == "model":
if asset_type == "model" and build_import_cache:
self._build_model_import_cache(record)
meta_payload = {
@ -341,8 +344,13 @@ class AssetDatabase:
self.save()
return dict(record)
def import_asset(self, source_path: str, preferred_subdir: str = "") -> dict:
return self.register_asset(source_path, preferred_subdir=preferred_subdir, copy_into_assets=True)
def import_asset(self, source_path: str, preferred_subdir: str = "", build_import_cache: bool = False) -> dict:
return self.register_asset(
source_path,
preferred_subdir=preferred_subdir,
copy_into_assets=True,
build_import_cache=build_import_cache,
)
def ensure_project_assets_registered(self):
self._sync_assets_from_meta_scan()
@ -351,4 +359,4 @@ class AssetDatabase:
if file_name.endswith(".meta"):
continue
asset_path = os.path.join(root, file_name)
self.register_asset(asset_path, copy_into_assets=False)
self.register_asset(asset_path, copy_into_assets=False, build_import_cache=False)

View File

@ -9,6 +9,9 @@ import struct
import tempfile
_GLTF_METADATA_CACHE = {}
def is_gltf_path(file_path: str) -> bool:
ext = os.path.splitext(str(file_path or ""))[1].lower()
return ext in {".gltf", ".glb"}
@ -88,20 +91,49 @@ def _load_gltf_json_payload(file_path: str):
def probe_gltf_metadata(file_path: str) -> dict:
payload = _load_gltf_json_payload(file_path)
if not payload:
file_path = _to_os_specific_path(file_path)
if not file_path or not os.path.exists(file_path):
return {
"is_gltf": False,
"has_animations": False,
"animation_count": 0,
}
try:
stat_info = os.stat(file_path)
cache_key = (
os.path.abspath(file_path),
int(stat_info.st_mtime_ns),
int(stat_info.st_size),
)
cached = _GLTF_METADATA_CACHE.get(cache_key)
if cached is not None:
return dict(cached)
except Exception:
cache_key = None
payload = _load_gltf_json_payload(file_path)
if not payload:
result = {
"is_gltf": False,
"has_animations": False,
"animation_count": 0,
}
if cache_key is not None:
_GLTF_METADATA_CACHE.clear()
_GLTF_METADATA_CACHE[cache_key] = dict(result)
return result
animations = payload.get("animations") or []
return {
result = {
"is_gltf": True,
"has_animations": bool(animations),
"animation_count": len(animations),
}
if cache_key is not None:
_GLTF_METADATA_CACHE.clear()
_GLTF_METADATA_CACHE[cache_key] = dict(result)
return result
def _resolve_cache_root(project_root: str = "") -> str:

View File

@ -87,27 +87,30 @@ class SceneManagerModelMixin:
gltf_meta = None
try:
from scene.gltf_support import ensure_gltf_visual_bam, probe_gltf_metadata
from scene.gltf_support import get_gltf_visual_bam_path, probe_gltf_metadata
gltf_meta = probe_gltf_metadata(filepath)
if gltf_meta.get("is_gltf"):
has_anim = gltf_meta.get("has_animations", False)
# 智能加载策略:
# 1. 如果模型有动画,则强制使用 panda3d-gltf 构建的可见性缓存BAM以保证动画支持。
# 1. 如果模型有动画,仅在缓存已存在时使用缓存;首次导入直接加载原始 glTF
# 避免同步构建 BAM 导致首次导入被完整解析两次。
# 2. 如果模型是纯静态场景,则跳过缓存,使用原生加载器(如 Assimp这样在大场景下更流畅。
if has_anim:
project_manager = getattr(getattr(self, "world", None), "project_manager", None)
project_root = getattr(project_manager, "current_project_path", "") if project_manager else ""
cached_visual_path = ensure_gltf_visual_bam(
cached_visual_path = get_gltf_visual_bam_path(
filepath,
project_root=project_root,
skip_animations=False, # 有动画的模型不应跳过动画
flatten_nodes=False,
)
if cached_visual_path and cached_visual_path != filepath:
if cached_visual_path and cached_visual_path != filepath and os.path.exists(cached_visual_path):
visual_load_path = cached_visual_path
print(f"[GLTF智能加载] 检测到动画,使用 panda3d-gltf 缓存: {cached_visual_path}")
else:
print(f"[GLTF智能加载] 检测到动画首次导入跳过同步BAM构建: {filepath}")
else:
print(f"[GLTF智能加载] 纯静态模型,跳过缓存以开启流畅模式: {filepath}")
except Exception as e:
@ -844,33 +847,29 @@ class SceneManagerModelMixin:
cNode.setIntoCollideMask(current_mask | model_collision_mask)
print(f"{model.getName()} 启用模型间碰撞检测")
# 获取模型的边界信息,使用与选择框相同的计算方法
minPoint = Point3()
maxPoint = Point3()
# 使用与选择框相同的calcTightBounds方法获取边界但是在局部坐标系中进行计算
# 这样计算出的包围盒直接贴合几何体,并且无论模型自身受到什么平移/缩放/旋转都不会发生两次形变!
if model.calcTightBounds(minPoint, maxPoint, model):
# 检查边界框的有效性
if (abs(minPoint.x) < 1e10 and abs(minPoint.y) < 1e10 and abs(minPoint.z) < 1e10 and
abs(maxPoint.x) < 1e10 and abs(maxPoint.y) < 1e10 and abs(maxPoint.z) < 1e10):
# 我们现在获取的是纯局部几何数据因此不再需要手动乘以100或应用旋转来抵消FBX形变
# 创建与选择框完全一致的碰撞体
cBox = CollisionBox(minPoint, maxPoint)
cNode.addSolid(cBox)
radius = max(maxPoint.x - minPoint.x, maxPoint.y - minPoint.y, maxPoint.z - minPoint.z) / 2
else:
# 使用默认球体
radius = 1.0
cSphere = CollisionSphere(Point3(0, 0, 0), radius)
cNode.addSolid(cSphere)
else:
# 使用默认球体
# 导入阶段避免使用 calcTightBounds 扫描全部几何体。
# 这里优先使用 Panda 已有包围体快速生成一个可用于选择/碰撞的近似球体。
radius = 1.0
center = Point3(0, 0, 0)
try:
bounds = model.getBounds()
if bounds and not bounds.isEmpty():
try:
center = bounds.getApproxCenter()
except Exception:
center = bounds.getCenter()
try:
radius = float(bounds.getRadius())
except Exception:
radius = 1.0
except Exception:
pass
if not (radius > 0.0 and radius < 1e10):
radius = 1.0
cSphere = CollisionSphere(Point3(0, 0, 0), radius)
cNode.addSolid(cSphere)
cSphere = CollisionSphere(center, radius)
cNode.addSolid(cSphere)
# 将碰撞节点附加到模型上
cNodePath = model.attachNewNode(cNode)

View File

@ -22,16 +22,17 @@ class CrossPlatformPathHandler:
print(f"路径处理器初始化 - 系统: {self.system}")
def normalize_model_path(self, filepath):
"""标准化模型文件路径"""
try:
def normalize_model_path(self, filepath):
"""标准化模型文件路径"""
try:
#print(f"\n=== 路径标准化处理 ===")
#print(f"原始路径: {filepath}")
#print(f"当前系统: {self.system}")
# 步骤1: 检查原始路径是否存在
if self._check_file_exists(filepath):
return self._panda3d_normalize(filepath)
# 步骤1: 检查原始路径是否存在
if self._check_file_exists(filepath):
existing_path = self._to_os_specific_existing_path(filepath) or filepath
return self._panda3d_normalize(existing_path)
# 步骤2: 路径修复尝试
fixed_path = self._attempt_path_fixes(filepath)
@ -51,10 +52,54 @@ class CrossPlatformPathHandler:
print(f"❌ 路径标准化失败: {e}")
return filepath
def _check_file_exists(self, filepath):
"""检查文件是否存在"""
exists = os.path.exists(filepath)
return exists
def _check_file_exists(self, filepath):
"""检查文件是否存在"""
try:
if filepath and os.path.exists(filepath):
return True
except Exception:
pass
try:
os_path = self._to_os_specific_existing_path(filepath)
if os_path and os.path.exists(os_path):
return True
except Exception:
pass
return False
def _to_os_specific_existing_path(self, filepath):
"""将 Panda 风格路径转换为当前系统下真实存在的路径。"""
path_text = os.fspath(filepath or "")
if not path_text:
return ""
if os.path.exists(path_text):
return os.path.normpath(path_text)
try:
for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
ctor = getattr(Filename, ctor_name, None)
if not ctor:
continue
try:
candidate = ctor(path_text).to_os_specific()
if candidate and os.path.exists(candidate):
return os.path.normpath(candidate)
except Exception:
continue
candidate = Filename(path_text).to_os_specific()
if candidate and os.path.exists(candidate):
return os.path.normpath(candidate)
except Exception:
pass
if len(path_text) >= 3 and path_text[0] in ("/", "\\") and path_text[1].isalpha() and path_text[2] in ("/", "\\"):
drive_path = f"{path_text[1].upper()}:{path_text[2:]}"
drive_path = os.path.normpath(drive_path)
if os.path.exists(drive_path):
return drive_path
return ""
def _panda3d_normalize(self, filepath):
"""使用Panda3D标准化路径"""

View File

@ -2875,6 +2875,14 @@ class SSBOEditor:
if self.pick_object(mpos.x, mpos.y):
return
# In SSBO picking mode, a miss should clear the current SSBO selection.
# Falling back to legacy collision picking here tends to immediately
# re-hit broad helper/collision shells from the selected root model,
# which makes "click blank space to deselect" fail for top-level nodes.
if self.has_active_selection():
self.clear_selection()
return
try:
win_width, win_height = self.base.win.getSize()
window_x = (float(mpos.x) + 1.0) * 0.5 * float(win_width)

View File

@ -1095,7 +1095,9 @@ class AnimationTools:
except Exception as e:
print(f"[Actor加载调试] 获取 tags 异常: {e}")
filepath = owner_model.getTag("model_path") if owner_model.hasTag("model_path") else ""
filepath = owner_model.getTag("resolved_actor_path") if owner_model.hasTag("resolved_actor_path") else ""
if not filepath and owner_model.hasTag("model_path"):
filepath = owner_model.getTag("model_path")
if not filepath and owner_model.hasTag("original_path"):
filepath = owner_model.getTag("original_path")
@ -1138,12 +1140,16 @@ class AnimationTools:
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
# 仅在当前路径和基础变体都不可直接访问时,才走较重的路径修复/搜索。
should_normalize_search = not any(os.path.exists(p) for p in candidate_paths if isinstance(p, str) and p)
if should_normalize_search:
try:
from scene import util
normalized_candidate = util.normalize_model_path(filepath)
if normalized_candidate:
candidate_paths.append(normalized_candidate)
except Exception:
pass
# 在 Resources/models 中按文件名兜底查找
filename = os.path.basename(filepath)
@ -1172,6 +1178,7 @@ class AnimationTools:
actor = _try_create_actor_from_source(p, f"文件路径({p})")
if actor:
owner_model.setTag("model_path", p)
owner_model.setTag("resolved_actor_path", p)
owner_model.setTag("has_animations", "true")
self._actor_cache[owner_model] = actor
return actor
@ -1180,6 +1187,7 @@ class AnimationTools:
actor = _try_create_actor_via_gltf_path(p)
if actor:
owner_model.setTag("model_path", p)
owner_model.setTag("resolved_actor_path", p)
owner_model.setTag("has_animations", "true")
self._actor_cache[owner_model] = actor
return actor
@ -1196,6 +1204,7 @@ class AnimationTools:
loaded_model.reparentTo(self.render)
loaded_model.hide()
owner_model.setTag("model_path", p)
owner_model.setTag("resolved_actor_path", p)
owner_model.setTag("has_animations", "true")
self._actor_cache[owner_model] = proxy
return proxy

View File

@ -46,12 +46,32 @@ class AppActions:
try:
if not file_path or not os.path.exists(file_path):
return False
try:
stat_info = os.stat(file_path)
cache_key = (
os.path.abspath(file_path),
int(stat_info.st_mtime_ns),
int(stat_info.st_size),
)
animation_cache = getattr(self, "_model_animation_probe_cache", None)
if animation_cache is None:
animation_cache = {}
self._model_animation_probe_cache = animation_cache
cached = animation_cache.get(cache_key)
if cached is not None:
return bool(cached)
except Exception:
cache_key = None
try:
from scene.gltf_support import probe_gltf_metadata
gltf_meta = probe_gltf_metadata(file_path)
if gltf_meta.get("is_gltf"):
return bool(gltf_meta.get("has_animations"))
result = bool(gltf_meta.get("has_animations"))
if cache_key is not None:
animation_cache.clear()
animation_cache[cache_key] = result
return result
except Exception:
pass
loader = getattr(self, "loader", None)
@ -76,7 +96,11 @@ class AppActions:
model.removeNode()
except Exception:
pass
return bool(has_animation)
result = bool(has_animation)
if cache_key is not None:
animation_cache.clear()
animation_cache[cache_key] = result
return result
except Exception:
return False

View File

@ -1,8 +1,69 @@
from imgui_bundle import imgui, imgui_ctx
import re
class EditorPanelsRightMaterialMixin:
"""Auto-split mixin from editor_panels_right.py."""
def _get_material_display_name(self, material, index):
name = ""
try:
if hasattr(material, "get_name"):
name = material.get_name()
if name:
name = str(name)
except Exception:
pass
if not name:
try:
if hasattr(material, "getName"):
name = material.getName()
if name:
name = str(name)
except Exception:
pass
if not name:
return f"材质{index + 1}"
clean_name = re.sub(r"__editable__.*$", "", name).strip()
return clean_name or f"材质{index + 1}"
def _get_material_stable_key(self, material, index):
identity_fn = getattr(self.app, "_get_material_identity_key", None)
if callable(identity_fn):
try:
return str(identity_fn(material))
except Exception:
pass
try:
return str(getattr(material, "this", None) or id(material))
except Exception:
return f"material_{index}"
def _get_materials_for_panel_display(self, node):
"""Read-only material lookup for panel rendering; avoid mutating scene state every frame."""
self._sync_material_panel_target(node)
panel_materials_fn = getattr(self.app, "_get_panel_edit_materials_for_node", None)
if callable(panel_materials_fn):
try:
materials = panel_materials_fn(node)
if materials:
return sorted(
materials,
key=lambda material: (
self._get_material_display_name(material, 0).lower(),
self._get_material_stable_key(material, 0),
),
)
except Exception:
pass
materials = self.app._get_node_materials(node)
return sorted(
materials,
key=lambda material: (
self._get_material_display_name(material, 0).lower(),
self._get_material_stable_key(material, 0),
),
)
def _ensure_material_edit_sessions(self):
if not hasattr(self, "_material_edit_sessions"):
self._material_edit_sessions = {}
@ -112,7 +173,7 @@ class EditorPanelsRightMaterialMixin:
def _draw_appearance_properties(self, node):
"""绘制材质属性Unity风格主材质入口"""
materials = self._ensure_node_materials_are_editable(node)
materials = self._get_materials_for_panel_display(node)
if not materials:
fallback_material = self.app._ensure_material_for_node(node)
materials = [fallback_material] if fallback_material else []
@ -222,16 +283,17 @@ class EditorPanelsRightMaterialMixin:
def _draw_material_properties(self, node):
"""绘制材质属性"""
materials = self._ensure_node_materials_are_editable(node)
materials = self._get_materials_for_panel_display(node)
if not materials:
imgui.text_colored((0.5, 0.5, 0.5, 1.0), "无材质")
return
for i, material in enumerate(materials):
material_name = material.get_name() if hasattr(material, 'get_name') and material.get_name() else f"材质{i + 1}"
material_name = self._get_material_display_name(material, i)
material_key = self._get_material_stable_key(material, i)
if imgui.collapsing_header(f"材质: {material_name}"):
if imgui.collapsing_header(f"材质: {material_name}##{material_key}"):
# PBR属性
imgui.text("PBR")
if hasattr(material, 'roughness') and material.roughness is not None:

View File

@ -1,4 +1,5 @@
import os
import re
from pathlib import Path
from imgui_bundle import imgui, imgui_ctx
@ -1358,6 +1359,7 @@ class PropertyHelpers:
except Exception:
source_name = ""
source_name = re.sub(r"__editable__.*$", "", str(source_name or "")).strip()
node_name = self._get_node_name(node, "node") if hasattr(self, "_get_node_name") else "node"
clone_name = f"{source_name or 'material'}__editable__{node_name}"
try: