EG/ssbo_component/runtime_importer.py
2026-03-18 18:34:44 +08:00

411 lines
16 KiB
Python

import os
from panda3d.core import Filename, GeomNode, Material, MaterialAttrib, NodePath
from .ssbo_controller import ObjectController
class RuntimeSSBOSceneImporter:
"""Minimal runtime-only SSBO scene importer without editor UI dependencies."""
def __init__(self, base_app):
self.base = base_app
self.controller = None
self.model = None
self.source_model = None
self.source_model_root = None
self.static_scene_root = None
self.interactive_scene_root = None
self.interactive_nodes = {}
def load_scene(self, scene_path, interactive_root_names=None):
source_model = self._load_source_model_from_path(scene_path)
self._clear_runtime_state()
source_root = self._ensure_source_model_root()
imported_root = source_model.copyTo(source_root)
self._set_node_name(imported_root, os.path.basename(scene_path) or "scene.bam")
self.source_model = imported_root
interactive_root_names = {str(name).strip() for name in (interactive_root_names or []) if str(name).strip()}
top_level_children = self._get_scene_top_level_children(imported_root)
if interactive_root_names and top_level_children:
self._build_release_split_scene(top_level_children, interactive_root_names)
return self.interactive_scene_root or self.static_scene_root
working_holder = NodePath("ssbo_source_scene_work")
working_root = source_root.copyTo(working_holder)
self.controller = ObjectController()
self.controller.bake_ids_and_collect(working_root)
self.model = self.controller.model
self.model.reparentTo(self.base.render)
try:
if not working_holder.isEmpty():
working_holder.removeNode()
except Exception:
pass
return self.model
def load_split_scene(self, *, static_scene_path="", interactive_scene_path="", interactive_root_names=None):
self._clear_runtime_state()
interactive_root_names = {str(name).strip() for name in (interactive_root_names or []) if str(name).strip()}
runtime_root = NodePath("runtime_split_scene")
loaded_any = False
if static_scene_path and os.path.exists(static_scene_path):
static_source = self._load_source_model_from_path(static_scene_path)
static_holder = NodePath("ssbo_static_scene_work")
static_root = static_source.copyTo(static_holder)
self.controller = ObjectController()
self.controller.bake_ids_and_collect(static_root)
self.model = self.controller.model
if self.model and not self.model.isEmpty():
self.model.reparentTo(runtime_root)
self.static_scene_root = self.model
loaded_any = True
try:
if not static_holder.isEmpty():
static_holder.removeNode()
except Exception:
pass
if interactive_scene_path and os.path.exists(interactive_scene_path):
interactive_source = self._load_source_model_from_path(interactive_scene_path)
interactive_root = interactive_source.copyTo(runtime_root)
self.interactive_scene_root = interactive_root
loaded_any = True
for child in self._get_scene_top_level_children(interactive_root):
child_name = self._get_node_name(child, "")
if not child_name:
continue
if interactive_root_names and child_name not in interactive_root_names:
continue
self.interactive_nodes[child_name] = child
if not loaded_any:
try:
if not runtime_root.isEmpty():
runtime_root.removeNode()
except Exception:
pass
raise RuntimeError("Failed to load split runtime scene")
runtime_root.reparentTo(self.base.render)
print(
f"[RuntimeSSBO] 已加载分层 cooked 场景: 静态={'yes' if self.static_scene_root else 'no'}, "
f"交互对象={len(self.interactive_nodes)}"
)
return runtime_root
def _clear_runtime_state(self):
for node in (self.model, self.static_scene_root, self.interactive_scene_root, self.source_model_root):
try:
if node and not node.isEmpty():
node.removeNode()
except Exception:
continue
self.controller = None
self.model = None
self.source_model = None
self.source_model_root = None
self.static_scene_root = None
self.interactive_scene_root = None
self.interactive_nodes = {}
def get_runtime_node_for_name(self, node_name):
return self.interactive_nodes.get(str(node_name or "").strip())
def _get_scene_top_level_children(self, imported_root):
children = []
try:
for child in imported_root.getChildren():
if child and not child.isEmpty():
children.append(child)
except Exception:
children = []
if children:
return children
return [imported_root] if imported_root and not imported_root.isEmpty() else []
def _build_release_split_scene(self, top_level_children, interactive_root_names):
static_holder = NodePath("runtime_static_scene")
interactive_holder = NodePath("runtime_interactive_scene")
interactive_count = 0
static_count = 0
for child in top_level_children:
child_name = self._get_node_name(child, "")
if child_name in interactive_root_names:
runtime_child = child.copyTo(interactive_holder)
self._set_node_name(runtime_child, child_name)
self.interactive_nodes[child_name] = runtime_child
interactive_count += 1
else:
child.copyTo(static_holder)
static_count += 1
if static_count:
self.static_scene_root = static_holder
self.static_scene_root.reparentTo(self.base.render)
try:
self.static_scene_root.flattenStrong()
except Exception as e:
print(f"静态场景合批失败,继续使用未合批结果: {e}")
else:
try:
if not static_holder.isEmpty():
static_holder.removeNode()
except Exception:
pass
if interactive_count:
self.interactive_scene_root = interactive_holder
self.interactive_scene_root.reparentTo(self.base.render)
else:
try:
if not interactive_holder.isEmpty():
interactive_holder.removeNode()
except Exception:
pass
print(f"[RuntimeSSBO] 运行时分层导入完成: 交互对象 {interactive_count} 个, 静态合批对象 {static_count}")
def _load_source_model_from_path(self, model_path):
source_model = None
last_error = None
for fn in self._build_filename_candidates(model_path):
try:
source_model = self.base.loader.loadModel(fn)
if source_model and not source_model.isEmpty():
break
except Exception as e:
last_error = e
source_model = None
if not source_model or source_model.isEmpty():
if last_error:
raise RuntimeError(f"Failed to load model '{model_path}': {last_error}")
raise RuntimeError(f"Failed to load model '{model_path}'")
self._fix_black_materials(source_model)
self._repair_missing_textures(source_model, model_path)
return source_model
def _build_filename_candidates(self, path_text):
candidates = []
seen = set()
for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
ctor = getattr(Filename, ctor_name, None)
if not ctor:
continue
try:
fn = ctor(path_text)
key = fn.getFullpath() if hasattr(fn, "getFullpath") else fn.get_fullpath()
if key in seen:
continue
seen.add(key)
candidates.append(fn)
except Exception:
continue
if not candidates:
try:
candidates.append(Filename(path_text))
except Exception:
pass
return candidates
def _ensure_source_model_root(self):
root = self.source_model_root
if root and not root.isEmpty():
return root
self.source_model_root = NodePath("ssbo_source_scene_root")
return self.source_model_root
def _set_node_name(self, node, name):
if not node:
return
try:
node.set_name(name)
return
except Exception:
pass
try:
node.setName(name)
except Exception:
pass
def _get_node_name(self, node, default_name=None):
if not node:
return default_name
try:
return node.get_name()
except Exception:
pass
try:
return node.getName()
except Exception:
return default_name
def _fix_black_materials(self, model):
try:
for geom_path in model.findAllMatches("**/+GeomNode"):
geom_node = geom_path.node()
node_state = geom_path.getState()
if node_state.hasAttrib(MaterialAttrib.getClassType()):
mat_attrib = node_state.getAttrib(MaterialAttrib.getClassType())
mat = mat_attrib.getMaterial()
if mat and self._is_dark_material(mat):
new_mat = Material(mat)
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
geom_path.setState(node_state.setAttrib(MaterialAttrib.make(new_mat)))
for i in range(geom_node.getNumGeoms()):
geom_state = geom_node.getGeomState(i)
if geom_state.hasAttrib(MaterialAttrib.getClassType()):
mat_attrib = geom_state.getAttrib(MaterialAttrib.getClassType())
mat = mat_attrib.getMaterial()
if mat and self._is_dark_material(mat):
new_mat = Material(mat)
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
geom_node.setGeomState(i, geom_state.setAttrib(MaterialAttrib.make(new_mat)))
else:
new_mat = Material()
new_mat.setBaseColor((0.8, 0.8, 0.8, 1.0))
new_mat.setDiffuse((0.8, 0.8, 0.8, 1.0))
new_mat.setSpecular((0.2, 0.2, 0.2, 1.0))
new_mat.setRoughness(0.8)
geom_node.setGeomState(i, geom_state.addAttrib(MaterialAttrib.make(new_mat)))
model.clearColor()
except Exception as e:
print(f"修复黑色模型材质时出错: {e}")
def _is_dark_material(self, material):
try:
if material.hasBaseColor():
c = material.getBaseColor()
elif material.hasDiffuse():
c = material.getDiffuse()
else:
return True
return c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05
except Exception:
return False
def _repair_missing_textures(self, model_np, model_path):
if not model_np or model_np.isEmpty():
return
search_dirs = self._build_texture_search_dirs(model_path)
texture_index = self._index_texture_files(search_dirs)
for node in [model_np] + list(model_np.findAllMatches("**")):
if not node or node.isEmpty():
continue
try:
stages = node.findAllTextureStages()
stage_count = stages.getNumTextureStages()
stage_at = stages.getTextureStage
except Exception:
continue
for i in range(stage_count):
stage = stage_at(i)
if not stage:
continue
try:
tex = node.getTexture(stage)
except Exception:
tex = None
if not tex or self._texture_is_valid(tex):
continue
basename = self._extract_texture_basename(tex)
if not basename:
continue
replacement = texture_index.get(basename.lower())
if replacement:
new_tex = self._load_texture_from_path(replacement)
if new_tex:
try:
node.setTexture(stage, new_tex, 1)
continue
except Exception:
pass
try:
node.clearTexture(stage)
except Exception:
pass
def _build_texture_search_dirs(self, model_path):
dirs = []
model_dir = os.path.dirname(os.path.abspath(model_path))
project_root = getattr(self.base, "project_path", "") or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def add_dir(path):
if path and os.path.isdir(path):
normalized = os.path.normpath(path)
if normalized not in dirs:
dirs.append(normalized)
add_dir(model_dir)
for sub in ("textures", "texture", "tex", "assets", "materials"):
add_dir(os.path.join(model_dir, sub))
add_dir(os.path.join(project_root, "resources", sub))
add_dir(os.path.join(project_root, "resources"))
return dirs
def _index_texture_files(self, dirs, limit=30000):
texture_exts = {".png", ".jpg", ".jpeg", ".tga", ".bmp", ".dds", ".ktx", ".ktx2", ".webp"}
index = {}
scanned = 0
for root_dir in dirs:
try:
for root, _, files in os.walk(root_dir):
for filename in files:
if os.path.splitext(filename)[1].lower() not in texture_exts:
continue
index.setdefault(filename.lower(), os.path.join(root, filename))
scanned += 1
if scanned >= limit:
return index
except Exception:
continue
return index
def _load_texture_from_path(self, texture_path):
for fn in self._build_filename_candidates(texture_path):
try:
tex = self.base.loader.loadTexture(fn)
if tex:
return tex
except Exception:
continue
return None
def _texture_is_valid(self, texture):
try:
x_size = texture.getXSize()
y_size = texture.getYSize()
return bool(x_size and y_size)
except Exception:
return False
def _extract_texture_basename(self, texture):
try:
path = texture.getFilename().toOsSpecific()
except Exception:
path = ""
if not path:
return ""
return os.path.basename(path)