EG/ssbo_component/ssbo_editor.py

2425 lines
95 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 sys
import os
import struct
import time
import types
from panda3d.core import (
Filename, loadPrcFileData, GeomVertexFormat,
GeomVertexWriter, InternalName, Shader, Texture, SamplerState,
Vec3, Vec4, Point2, Point3, LMatrix4f, ShaderBuffer, GeomEnums, OmniBoundingVolume,
TransparencyAttrib, BoundingSphere, NodePath,
GraphicsEngine, WindowProperties, FrameBufferProperties,
GraphicsPipe, GraphicsOutput, Camera, DisplayRegion, OrthographicLens,
BoundingBox, BitMask32, Material, MaterialAttrib, ColorAttrib, TextureAttrib, PNMImage
)
# p3dimgui.backend first tries `from shaders import *`, which can be shadowed by
# project folders named `shaders/` and leave VERT_SHADER/FRAG_SHADER undefined.
# Seed a valid fallback module before importing p3dimgui.
_shaders_mod = sys.modules.get("shaders")
if not (_shaders_mod and hasattr(_shaders_mod, "VERT_SHADER") and hasattr(_shaders_mod, "FRAG_SHADER")):
_shaders_mod = types.ModuleType("shaders")
_shaders_mod.FRAG_SHADER = """
#version 120
varying vec2 texcoord;
varying vec4 color;
uniform sampler2D p3d_Texture0;
void main() {
gl_FragColor = color * texture2D(p3d_Texture0, texcoord);
}
"""
_shaders_mod.VERT_SHADER = """
#version 120
attribute vec4 p3d_Vertex;
attribute vec4 p3d_Color;
varying vec2 texcoord;
varying vec4 color;
uniform mat4 p3d_ModelViewProjectionMatrix;
void main() {
texcoord = p3d_Vertex.zw;
color = p3d_Color.bgra;
gl_Position = p3d_ModelViewProjectionMatrix * vec4(p3d_Vertex.x, 0.0, -p3d_Vertex.y, 1.0);
}
"""
sys.modules["shaders"] = _shaders_mod
from p3dimgui import ImGuiBackend
from imgui_bundle import imgui
from rpcore.effect import Effect
from .ssbo_controller import ObjectController
from core.selection_outline import SelectionOutlineManager
class SSBOEditor:
"""
SSBO Editor Component
====================
Encapsulates the SSBO rendering, ImGui editor, and interaction logic.
Can be integrated into any ShowBase application using RenderPipeline.
"""
def __init__(self, base_app, render_pipeline, model_path=None, font_path=None):
self.base = base_app
self.rp = render_pipeline
self.controller = None
self.model = None
self.source_model = None
self.source_model_root = None
self._source_child_base_mats = {}
self.last_import_tree_key = None
self.last_import_root_name = None
self.ssbo = None
self.font_path = font_path
self._transform_gizmo = None
self._ssbo_transform_active = False
self._ssbo_selected_local_indices = []
self._ssbo_gizmo_proxy = None
# Internal State
self.selected_name = None
self.selected_ids = []
self.search_text = ""
self.last_search_text = None
self.filtered_nodes = []
self.debug_mode = False
self.keys = {}
self.pick_mask = BitMask32.bit(29)
self.pick_buffer = None
self._empty_pick_scene = NodePath("ssbo_pick_empty")
# Avoid heavy per-frame sync for huge group selections.
self._pick_sync_bg_limit = 256
self._last_group_sync_mat = None
self._last_single_sync_gid = None
self._last_single_sync_mat = None
# Performance toggle: forcing shadow tasks every frame is expensive.
# Keep it off by default so frustum/content reduction has clearer FPS impact.
self.realtime_shadow_updates = False
self._scheduler_tasks_original = None
self._realtime_shadow_tasks_enabled = False
self._outline_manager = getattr(self.base, "_selection_outline_manager", None)
if self._outline_manager is None:
self._outline_manager = SelectionOutlineManager(self.base)
setattr(self.base, "_selection_outline_manager", self._outline_manager)
# Initialize ImGui Backend if not already present
if not hasattr(self.base, 'imgui_backend'):
print("[SSBOEditor] Initializing ImGui Backend...")
self.base.imgui_backend = ImGuiBackend()
self.load_font()
# Register Events
self.base.accept("imgui-new-frame", self.draw_imgui)
self.base.accept("f", self.focus_on_selected)
self.base.accept("d", self.toggle_debug)
self.base.accept("mouse1", self.on_mouse_click)
# Register Input Tasks
for key in ['arrow_up', 'arrow_down', 'arrow_left', 'arrow_right', 'z', 'x']:
self.base.accept(key, self.keys.__setitem__, [key, True])
self.base.accept(f"{key}-up", self.keys.__setitem__, [key, False])
# Add Tasks
self.base.taskMgr.add(self.update_task, "update_task")
# Load Model if provided
if model_path:
self.load_model(model_path)
def _capture_scheduler_tasks_snapshot(self):
scheduler = getattr(self.rp, "task_scheduler", None)
if not scheduler or not hasattr(scheduler, "_tasks"):
return
if self._scheduler_tasks_original is None:
self._scheduler_tasks_original = [list(frame_tasks) for frame_tasks in scheduler._tasks]
def _enable_realtime_shadow_tasks(self):
"""
Force PSSM-related scheduled tasks to run every frame to avoid visible
shadow lag/ghosting while editing moving objects.
"""
scheduler = getattr(self.rp, "task_scheduler", None)
if not scheduler or not hasattr(scheduler, "_tasks"):
return
self._capture_scheduler_tasks_snapshot()
required = {
"pssm_scene_shadows",
"pssm_distant_shadows",
"pssm_convert_distant_to_esm",
"pssm_blur_distant_vert",
"pssm_blur_distant_horiz",
}
changed = False
for frame_tasks in scheduler._tasks:
for task_name in required:
if task_name not in frame_tasks:
frame_tasks.append(task_name)
changed = True
if changed:
print("[SSBOEditor] Realtime shadow tasks enabled (PSSM updates every frame).")
self._realtime_shadow_tasks_enabled = True
def _disable_realtime_shadow_tasks(self):
"""Restore scheduler layout captured before realtime shadow override."""
scheduler = getattr(self.rp, "task_scheduler", None)
if not scheduler or not hasattr(scheduler, "_tasks"):
return
if self._scheduler_tasks_original is None:
self._realtime_shadow_tasks_enabled = False
return
scheduler._tasks[:] = [list(frame_tasks) for frame_tasks in self._scheduler_tasks_original]
self._realtime_shadow_tasks_enabled = False
def set_realtime_shadow_updates(self, enabled):
"""Public toggle for aggressive per-frame shadow updates."""
self.realtime_shadow_updates = bool(enabled)
if self.realtime_shadow_updates:
self._enable_realtime_shadow_tasks()
else:
self._disable_realtime_shadow_tasks()
def load_font(self):
"""Load custom font for ImGui"""
io = imgui.get_io()
# Load Chinese Glyph Ranges
glyph_ranges = None
try:
if hasattr(io.fonts, 'get_glyph_ranges_chinese_full'):
glyph_ranges = io.fonts.get_glyph_ranges_chinese_full()
elif hasattr(io.fonts, 'get_glyph_ranges_chinese_simplified_common'):
glyph_ranges = io.fonts.get_glyph_ranges_chinese_simplified_common()
except Exception as e:
print(f"[SSBOEditor] Warning: Could not get Chinese glyph ranges: {e}")
try:
if self.font_path and os.path.exists(self.font_path):
io.fonts.clear()
# If glyph_ranges is None, it uses default (Basic Latin)
if glyph_ranges:
io.fonts.add_font_from_file_ttf(self.font_path, 18.0, glyph_ranges=glyph_ranges)
else:
io.fonts.add_font_from_file_ttf(self.font_path, 18.0)
else:
# Fallback to default or common font
default_font = os.path.join(os.path.dirname(os.path.dirname(__file__)), "font", "msyh.ttc")
if os.path.exists(default_font):
io.fonts.clear()
io.fonts.add_font_from_file_ttf(default_font, 18.0, glyph_ranges=glyph_ranges)
else:
io.fonts.clear()
io.fonts.add_font_default()
except Exception as e:
print(f"[SSBOEditor] Font load error: {e}")
io.fonts.clear()
io.fonts.add_font_default()
def _fixBlackMaterials(self, model):
try:
from panda3d.core import MaterialAttrib, Material, GeomNode
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:
is_black = False
if mat.hasBaseColor():
c = mat.getBaseColor()
if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
is_black = True
elif mat.hasDiffuse():
c = mat.getDiffuse()
if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
is_black = True
if is_black or not (mat.hasBaseColor() or mat.hasDiffuse()):
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:
is_black = False
if mat.hasBaseColor():
c = mat.getBaseColor()
if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
is_black = True
elif mat.hasDiffuse():
c = mat.getDiffuse()
if c.x <= 0.05 and c.y <= 0.05 and c.z <= 0.05:
is_black = True
if is_black or not (mat.hasBaseColor() or mat.hasDiffuse()):
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 _load_source_model_from_path(self, model_path, apply_black_fix=True, repair_textures=True):
"""Load a source model NodePath from disk without touching current runtime state."""
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.is_empty():
break
except Exception as e:
last_error = e
source_model = None
if not source_model or source_model.is_empty():
if last_error:
raise RuntimeError(f"Failed to load model '{model_path}': {last_error}")
raise RuntimeError(f"Failed to load model '{model_path}'")
if apply_black_fix:
self._fixBlackMaterials(source_model)
if repair_textures:
self._repair_missing_textures(source_model, model_path)
return source_model
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 _iter_children(self, node):
if not node:
return []
try:
return list(node.get_children())
except Exception:
try:
return list(node.getChildren())
except Exception:
return []
def _ensure_source_model_root(self):
root = self.source_model_root
if root:
try:
if not root.is_empty():
return root
except Exception:
try:
if not root.isEmpty():
return root
except Exception:
pass
self.source_model_root = NodePath("ssbo_source_scene_root")
return self.source_model_root
def _make_unique_source_child_name(self, desired_name):
root = self._ensure_source_model_root()
existing_names = set()
for child in self._iter_children(root):
try:
existing_names.add(child.get_name())
except Exception:
try:
existing_names.add(child.getName())
except Exception:
continue
base_name = desired_name or "imported_model"
if base_name not in existing_names:
return base_name
stem, ext = os.path.splitext(base_name)
index = 2
while True:
candidate = f"{stem}_{index}{ext}"
if candidate not in existing_names:
return candidate
index += 1
def _capture_source_child_base_mats(self):
"""Capture baseline local mats for each top-level source child."""
self._source_child_base_mats = {}
root = self.source_model_root
if not root:
return
for child in self._iter_children(root):
if not self._node_is_valid(child):
continue
name = self._get_node_name(child, None)
if not name:
continue
try:
self._source_child_base_mats[name] = LMatrix4f(child.get_mat())
except Exception:
try:
self._source_child_base_mats[name] = LMatrix4f(child.getMat())
except Exception:
continue
def _get_top_level_group_keys(self):
if not self.controller or not getattr(self.controller, "tree_root_key", None):
return []
root_key = self.controller.tree_root_key
root_node = self.controller.tree_nodes.get(root_key)
if not root_node:
return []
return list(root_node.get("children", []))
def _snapshot_top_level_transforms_to_source_root(self):
"""Persist current top-level imported model transforms back into the source scene root."""
if not self.controller or not self.model or not self.source_model_root:
return
source_children = {}
for child in self._iter_children(self.source_model_root):
try:
source_children[child.get_name()] = child
except Exception:
try:
source_children[child.getName()] = child
except Exception:
continue
for key in self._get_top_level_group_keys():
display_name = self.controller.display_names.get(key, key)
source_child = source_children.get(display_name)
if not source_child:
continue
group_ids = self.controller.name_to_ids.get(key, [])
if not group_ids:
continue
representative_id = None
for gid in group_ids:
obj_np = self.controller.id_to_object_np.get(gid)
if obj_np and not obj_np.is_empty():
representative_id = gid
break
if representative_id is None:
continue
try:
current_mat = LMatrix4f(self.controller.id_to_object_np[representative_id].get_mat(self.model))
except Exception:
try:
current_mat = LMatrix4f(self.controller.id_to_object_np[representative_id].getMat(self.model))
except Exception:
continue
if representative_id >= len(self.controller.global_transforms):
continue
original_mat = LMatrix4f(self.controller.global_transforms[representative_id])
inv_original = LMatrix4f(original_mat)
try:
inv_original.invertInPlace()
except Exception:
try:
inv_original.invert_in_place()
except Exception:
continue
base_child_mat = self._source_child_base_mats.get(display_name)
if base_child_mat is None:
try:
base_child_mat = LMatrix4f(source_child.get_mat())
except Exception:
try:
base_child_mat = LMatrix4f(source_child.getMat())
except Exception:
continue
delta_mat = current_mat * inv_original
try:
source_child.set_mat(delta_mat * base_child_mat)
except Exception:
try:
source_child.setMat(delta_mat * base_child_mat)
except Exception:
continue
def _resolve_source_node_by_tree_key(self, tree_key):
"""Resolve controller tree key (e.g. 0/1/2) to source_model_root node."""
if not self.source_model_root or not tree_key:
return None
parts = str(tree_key).split("/")
if not parts or parts[0] != "0":
return None
node = self.source_model_root
for part in parts[1:]:
try:
child_index = int(part)
except Exception:
return None
try:
node = node.get_child(child_index)
except Exception:
try:
node = node.getChild(child_index)
except Exception:
return None
if not self._node_is_valid(node):
return None
return node
def _snapshot_runtime_materials_to_source_root(self):
"""
Persist runtime-edited material/geom render state back to source_model_root.
This keeps project save/load consistent for SSBO editing workflow.
"""
controller = self.controller
if not controller or not self.source_model_root:
return
synced = 0
effect_tags_synced = set()
root_effect_tags = {}
property_helpers = getattr(self.world, "property_helpers", None) if getattr(self, "world", None) else None
capture_snapshot_fn = getattr(property_helpers, "_capture_node_material_snapshot", None) if property_helpers else None
normalize_snapshot_fn = getattr(property_helpers, "_normalize_material_snapshot", None) if property_helpers else None
apply_snapshot_fn = getattr(property_helpers, "_apply_node_material_snapshot", None) if property_helpers else None
get_materials_fn = getattr(property_helpers, "_get_node_materials", None) if property_helpers else None
ensure_material_fn = getattr(property_helpers, "_ensure_material_for_node", None) if property_helpers else None
try:
model_root = self.model
if model_root and not model_root.is_empty():
for tag_name in (
"material_effect_metallic_enabled",
"material_effect_default_texture_enabled",
"material_effect_parallax_enabled",
"material_render_effect_signature",
):
if model_root.hasTag(tag_name):
root_effect_tags[tag_name] = model_root.getTag(tag_name)
except Exception:
root_effect_tags = {}
def _node_has_metallic_texture(node_np):
try:
stages = node_np.findAllTextureStages()
for i in range(stages.getNumTextureStages()):
stage = stages.getTextureStage(i)
if not stage:
continue
try:
sname = (stage.getName() or "").lower()
except Exception:
sname = ""
# convention in project helpers and RP metallic workflow
if "metallic" in sname or sname == "p3d_texture5":
return True
except Exception:
pass
return False
def _clone_snapshot_for_target(source_snapshot, target_node):
if not callable(normalize_snapshot_fn):
return None
snapshot = normalize_snapshot_fn(source_snapshot)
if snapshot is None:
return None
target_materials = []
try:
if callable(get_materials_fn):
target_materials = list(get_materials_fn(target_node) or [])
except Exception:
target_materials = []
if not target_materials and callable(ensure_material_fn):
try:
fallback = ensure_material_fn(target_node)
if fallback is not None:
target_materials = [fallback]
except Exception:
target_materials = []
source_entries = snapshot.get("materials", []) or []
cloned_entries = []
for idx, entry in enumerate(source_entries):
target_material = target_materials[idx] if idx < len(target_materials) else None
cloned_entries.append({
"material": target_material,
"base_color": entry.get("base_color"),
"roughness": entry.get("roughness"),
"metallic": entry.get("metallic"),
"ior": entry.get("ior"),
"emission": entry.get("emission"),
})
node_state = snapshot.get("node_state", {}) or {}
textures = dict(node_state.get("textures", {}) or {})
effect_tags = dict(node_state.get("effect_tags", {}) or {})
return {
"materials": cloned_entries,
"node_state": {
"textures": textures,
"effect_tags": effect_tags,
},
}
grouped_entries = {}
def _snapshot_score(snapshot, obj_np):
score = 0
try:
snapshot = normalize_snapshot_fn(snapshot) if callable(normalize_snapshot_fn) else snapshot
except Exception:
pass
if isinstance(snapshot, dict):
node_state = snapshot.get("node_state", {}) or {}
textures = node_state.get("textures", {}) or {}
effect_tags = node_state.get("effect_tags", {}) or {}
score += len([value for value in textures.values() if value]) * 100
score += len([value for value in effect_tags.values() if value]) * 50
for entry in snapshot.get("materials", []) or []:
if entry.get("base_color") is not None:
score += 5
for scalar_name in ("roughness", "metallic", "ior"):
if entry.get(scalar_name) is not None:
score += 3
if entry.get("emission") is not None:
score += 2
try:
if _node_has_metallic_texture(obj_np):
score += 25
except Exception:
pass
return score
for gid, obj_np in controller.id_to_object_np.items():
if not self._node_is_valid(obj_np):
continue
owner_key = controller.id_to_name.get(gid)
if not owner_key:
continue
source_node = self._resolve_source_node_by_tree_key(owner_key)
if not self._node_is_valid(source_node):
continue
source_snapshot = None
if callable(capture_snapshot_fn):
try:
source_snapshot = capture_snapshot_fn(obj_np)
except Exception:
source_snapshot = None
source_node_key = id(source_node)
entry = grouped_entries.get(source_node_key)
candidate_score = _snapshot_score(source_snapshot, obj_np)
if entry is None or candidate_score > entry["score"]:
grouped_entries[source_node_key] = {
"gid": gid,
"obj_np": obj_np,
"source_node": source_node,
"snapshot": source_snapshot,
"score": candidate_score,
}
for source_node_key, entry in grouped_entries.items():
gid = entry["gid"]
obj_np = entry["obj_np"]
source_node = entry["source_node"]
source_snapshot = entry["snapshot"]
if source_node_key not in effect_tags_synced:
inferred_metallic = _node_has_metallic_texture(obj_np)
for tag_name in (
"material_effect_metallic_enabled",
"material_effect_default_texture_enabled",
"material_effect_parallax_enabled",
"material_render_effect_signature",
):
try:
if obj_np.hasTag(tag_name):
source_node.setTag(tag_name, obj_np.getTag(tag_name))
elif tag_name == "material_effect_metallic_enabled" and inferred_metallic:
source_node.setTag(tag_name, "1")
elif tag_name in root_effect_tags:
source_node.setTag(tag_name, root_effect_tags[tag_name])
except Exception:
pass
texture_slot_tags = (
"material_texture_diffuse",
"material_texture_normal",
"material_texture_ior",
"material_texture_roughness",
"material_texture_parallax",
"material_texture_metallic",
"material_texture_emission",
"material_texture_ao",
"material_texture_alpha",
"material_texture_detail",
"material_texture_gloss",
)
for tag_name in texture_slot_tags:
try:
if obj_np.hasTag(tag_name):
source_node.setTag(tag_name, obj_np.getTag(tag_name))
elif source_node.hasTag(tag_name):
source_node.clearTag(tag_name)
except Exception:
pass
effect_tags_synced.add(source_node_key)
if source_snapshot is not None and callable(apply_snapshot_fn):
try:
target_snapshot = _clone_snapshot_for_target(source_snapshot, source_node)
if target_snapshot is not None:
apply_snapshot_fn(source_node, target_snapshot)
synced += 1
continue
except Exception:
pass
runtime_geom_state = None
try:
geom_paths = obj_np.findAllMatches("**/+GeomNode")
if geom_paths and geom_paths.getNumPaths() > 0:
runtime_geom_state = geom_paths.getPath(0).getNetState()
except Exception:
runtime_geom_state = None
if runtime_geom_state is None:
continue
try:
src_gnode = source_node.node()
set_src_state = getattr(src_gnode, "set_geom_state", None) or getattr(src_gnode, "setGeomState", None)
get_src_count = getattr(src_gnode, "get_num_geoms", None) or getattr(src_gnode, "getNumGeoms", None)
src_geom_count = int(get_src_count()) if callable(get_src_count) else 0
except Exception:
continue
src_geom_index = controller.id_to_geom_index.get(gid, 0)
if src_geom_index < 0 or src_geom_index >= src_geom_count:
continue
try:
if callable(set_src_state):
set_src_state(src_geom_index, runtime_geom_state)
synced += 1
except Exception:
continue
if synced:
print(f"[SSBOEditor] Synced runtime material states back to source tree: {synced}")
def _restore_saved_material_bindings_from_tags(self, root_np):
"""Rebind saved texture/effect tags back onto loaded source nodes."""
if not self._node_is_valid(root_np):
return 0
property_helpers = getattr(self.base, "property_helpers", None)
capture_snapshot_fn = getattr(property_helpers, "_capture_node_material_snapshot", None) if property_helpers else None
apply_snapshot_fn = getattr(property_helpers, "_apply_node_material_snapshot", None) if property_helpers else None
texture_slots_fn = getattr(property_helpers, "_get_material_texture_slots", None) if property_helpers else None
if not callable(capture_snapshot_fn) or not callable(apply_snapshot_fn):
return 0
texture_types = []
if callable(texture_slots_fn):
try:
texture_types = list((texture_slots_fn() or {}).keys())
except Exception:
texture_types = []
if not texture_types:
texture_types = [
"diffuse", "normal", "ior", "roughness", "parallax",
"metallic", "emission", "ao", "alpha", "detail", "gloss",
]
effect_tag_names = (
"material_effect_metallic_enabled",
"material_effect_default_texture_enabled",
"material_effect_parallax_enabled",
"material_render_effect_signature",
)
restored = 0
try:
descendant_nodes = list(root_np.find_all_matches("**"))
except Exception:
try:
descendant_nodes = list(root_np.findAllMatches("**"))
except Exception:
descendant_nodes = []
for node in [root_np] + descendant_nodes:
if not self._node_is_valid(node):
continue
has_texture_tags = any(node.hasTag(f"material_texture_{texture_type}") for texture_type in texture_types)
has_effect_tags = any(node.hasTag(tag_name) for tag_name in effect_tag_names)
if not (has_texture_tags or has_effect_tags):
continue
try:
snapshot = capture_snapshot_fn(node)
except Exception:
snapshot = None
if not snapshot:
continue
try:
apply_snapshot_fn(node, snapshot)
restored += 1
except Exception:
continue
if restored:
print(f"[SSBOEditor] Restored saved material bindings from tags: {restored}")
return restored
def _clear_runtime_state(self, preserve_source_models=False):
"""Remove runtime SSBO controller/model state while optionally keeping source snapshots."""
self.clear_selection()
self._cleanup_group_proxy()
self._reset_pick_sync_cache()
controller = self.controller
pick_model = getattr(controller, "pick_model", None) if controller else None
model = self.model
removable_nodes = [pick_model, model]
if not preserve_source_models:
removable_nodes.extend([self.source_model, self.source_model_root])
for node in removable_nodes:
if not node:
continue
try:
if not node.is_empty():
node.remove_node()
except Exception:
try:
if not node.isEmpty():
node.removeNode()
except Exception:
pass
self.controller = None
self.model = None
self.selected_name = None
self.selected_ids = []
self.last_import_tree_key = None
self.last_import_root_name = None
if not preserve_source_models:
self.source_model = None
self.source_model_root = None
self._source_child_base_mats = {}
self._sync_pick_scene_binding()
def _get_source_root_children(self):
root = self.source_model_root
if not root:
return []
return [child for child in self._iter_children(root) if self._node_is_valid(child)]
def _rebuild_or_clear_runtime_from_current_source(self, highlight_root_name=None):
if self._get_source_root_children():
self.source_model = self.source_model_root
self._rebuild_runtime_from_source_root(highlight_root_name=highlight_root_name)
return self.model
self.last_import_tree_key = None
self.last_import_root_name = None
self.source_model = self.source_model_root
self._sync_pick_scene_binding()
return None
def find_source_child_by_name(self, child_name):
if not child_name:
return None
for child in self._get_source_root_children():
if self._get_node_name(child) == child_name:
return child
return None
def detach_source_child(self, child_name=None, child_np=None):
target = child_np if self._node_is_valid(child_np) else self.find_source_child_by_name(child_name)
if not self._node_is_valid(target):
return None
if self.controller and self.model:
self._snapshot_top_level_transforms_to_source_root()
self._clear_runtime_state(preserve_source_models=True)
try:
target.detach_node()
except Exception:
try:
target.detachNode()
except Exception:
return None
self._rebuild_or_clear_runtime_from_current_source()
return target
def attach_source_child(self, child_np, highlight_root_name=None):
if not self._node_is_valid(child_np):
return None
if self.controller and self.model:
self._snapshot_top_level_transforms_to_source_root()
self._clear_runtime_state(preserve_source_models=True)
source_root = self._ensure_source_model_root()
target_name = highlight_root_name or self._get_node_name(child_np, "imported_model")
self._set_node_name(child_np, target_name)
try:
child_np.reparent_to(source_root)
except Exception:
try:
child_np.reparentTo(source_root)
except Exception:
return None
self._rebuild_or_clear_runtime_from_current_source(highlight_root_name=target_name)
return self.model
def _rebuild_runtime_from_source_root(self, highlight_root_name=None):
root = self._ensure_source_model_root()
working_holder = NodePath("ssbo_source_scene_work")
working_root = root.copy_to(working_holder)
self.controller = ObjectController()
count = self.controller.bake_ids_and_collect(working_root)
self.model = self.controller.model
self.model.reparent_to(self.base.render)
self.set_realtime_shadow_updates(self.realtime_shadow_updates)
self.setup_gpu_picking()
self._sync_pick_model_transform()
self.last_import_tree_key = None
self.last_import_root_name = highlight_root_name
if highlight_root_name:
root_key = getattr(self.controller, "tree_root_key", None)
root_node = self.controller.tree_nodes.get(root_key, {}) if root_key else {}
for child_key in root_node.get("children", []):
if self.controller.display_names.get(child_key) == highlight_root_name:
self.last_import_tree_key = child_key
break
try:
if not working_holder.is_empty():
working_holder.remove_node()
except Exception:
try:
if not working_holder.isEmpty():
working_holder.removeNode()
except Exception:
pass
print(f"[SSBOEditor] Model loaded. Total objects: {count}")
def load_model(
self,
model_path,
keep_source_model=False,
append=False,
scene_package_import=False,
):
"""Load and process one model into the aggregated SSBO scene."""
print(f"[SSBOEditor] Loading model: {model_path}")
# scene_package_import: loading saved scene.bam from project.
# Keep stored material/texture states intact; repair heuristics can
# misclassify valid packed/relative texture refs and cause dark materials.
should_repair_textures = not scene_package_import
should_fix_black_materials = not scene_package_import
source_model = self._load_source_model_from_path(
model_path,
apply_black_fix=should_fix_black_materials,
repair_textures=should_repair_textures,
)
model_name = os.path.basename(model_path)
if model_name and not scene_package_import:
self._set_node_name(source_model, model_name)
if append and self.source_model_root:
if self.controller and self.model:
self._snapshot_top_level_transforms_to_source_root()
self._clear_runtime_state(preserve_source_models=True)
else:
self._clear_runtime_state(preserve_source_models=False)
source_root = self._ensure_source_model_root()
# 项目场景包导入(scene.bam)时,避免再包一层 "scene.bam" 根节点,
# 直接把其顶层子节点并入 source_root保持场景树与保存时一致。
if scene_package_import:
imported_roots = []
children = []
try:
children = [c for c in source_model.get_children() if c and not c.is_empty()]
except Exception:
try:
children = [c for c in source_model.getChildren() if c and not c.isEmpty()]
except Exception:
children = []
for child in children:
try:
child_name = self._get_node_name(child, "")
if child_name in {"render", "render2d", "aspect2d"}:
continue
imported_child = child.copyTo(source_root)
imported_roots.append(imported_child)
except Exception:
continue
if not imported_roots:
fallback_name = self._get_node_name(source_model, "scene_root")
unique_root_name = self._make_unique_source_child_name(fallback_name)
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)
imported_roots = [imported_root]
self.source_model = source_root
self._restore_saved_material_bindings_from_tags(source_root)
self._capture_source_child_base_mats()
self._rebuild_runtime_from_source_root(highlight_root_name=None)
if len(imported_roots) == 1:
return imported_roots[0]
return source_root
unique_root_name = self._make_unique_source_child_name(model_name or "imported_model")
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)
if keep_source_model and not append:
self.source_model = imported_root
else:
self.source_model = source_root
self._capture_source_child_base_mats()
self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name)
return imported_root
def _build_filename_candidates(self, path_text):
"""Build Filename candidates with wide-char first for Windows CJK paths."""
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.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 _load_texture_from_path(self, texture_path):
"""Load texture with robust path constructors."""
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 _build_texture_search_dirs(self, model_path):
"""Build candidate directories for missing texture recovery."""
dirs = []
model_dir = os.path.dirname(os.path.abspath(model_path))
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def add_dir(path):
if not path:
return
path = os.path.normpath(path)
if path in dirs:
return
if os.path.isdir(path):
dirs.append(path)
add_dir(model_dir)
try:
if os.path.isdir(model_dir):
for item in os.listdir(model_dir):
if item.lower().endswith('.fbm'):
fbm_dir = os.path.join(model_dir, item)
if os.path.isdir(fbm_dir):
add_dir(fbm_dir)
for sub in ("textures", "texture", "tex", "assets", "materials"):
add_dir(os.path.join(fbm_dir, sub))
except Exception:
pass
for sub in ("textures", "texture", "tex", "assets", "materials"):
add_dir(os.path.join(model_dir, sub))
parent = model_dir
for _ in range(2):
parent = os.path.dirname(parent)
if not parent:
break
for sub in ("textures", "texture", "tex", "assets", "materials"):
add_dir(os.path.join(parent, sub))
add_dir(os.path.join(project_root, "Resources"))
add_dir(os.path.join(project_root, "Resources", "textures"))
add_dir(os.path.join(project_root, "Resources", "materials"))
add_dir(os.path.join(project_root, "Resources", "models"))
return dirs
def _index_texture_files(self, dirs, limit=30000):
"""Index texture files by basename for fast lookup."""
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:
ext = os.path.splitext(filename)[1].lower()
if ext not in texture_exts:
continue
key = filename.lower()
if key not in index:
index[key] = os.path.join(root, filename)
scanned += 1
if scanned >= limit:
return index
except Exception:
continue
return index
def _repair_missing_textures(self, model_np, model_path):
"""
Repair broken texture paths by basename search; if unresolved, clear that
missing texture binding to avoid black PBR sampling.
"""
if not model_np or model_np.is_empty():
return
search_dirs = self._build_texture_search_dirs(model_path)
texture_index = self._index_texture_files(search_dirs)
fixed = 0
cleared = 0
white_tex = self._get_white_fallback_texture()
nodes = [model_np] + list(model_np.find_all_matches("**"))
for node in nodes:
if not node or node.is_empty():
continue
try:
stages = node.find_all_texture_stages()
except Exception:
try:
stages = node.findAllTextureStages()
except Exception:
continue
try:
stage_count = stages.get_num_texture_stages()
stage_at = stages.get_texture_stage
except Exception:
try:
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.get_texture(stage)
except Exception:
tex = node.getTexture(stage)
if not tex:
continue
if self._texture_is_valid(tex):
continue
basename = self._extract_texture_basename(tex)
if not basename:
continue
replacement = texture_index.get(basename.lower())
if replacement:
try:
new_tex = self._load_texture_from_path(replacement)
if new_tex:
try:
node.set_texture(stage, new_tex, 1)
except Exception:
node.setTexture(stage, new_tex, 1)
fixed += 1
continue
except Exception:
pass
# Missing texture with no replacement: CLEAR the texture binding.
# Do NOT use a white fallback texture, because if this is a Metallic or Roughness
# map, pure white will force Metallic=1.0 and Roughness=1.0, which turns models black!
try:
try:
node.clear_texture(stage)
except Exception:
node.clearTexture(stage)
cleared += 1
except Exception:
pass
# GeomState-level texture bindings (common in imported FBX/GLTF):
# inspect and apply the same fallback on this GeomNode path.
try:
gnode = node.node()
get_num_geoms = getattr(gnode, "get_num_geoms", None) or getattr(gnode, "getNumGeoms", None)
get_geom_state = getattr(gnode, "get_geom_state", None) or getattr(gnode, "getGeomState", None)
geom_count = int(get_num_geoms()) if callable(get_num_geoms) else 0
except Exception:
geom_count = 0
for gi in range(geom_count):
try:
state = get_geom_state(gi)
except Exception:
continue
if state is None:
continue
tattr = None
try:
if state.has_attrib(TextureAttrib.get_class_type()):
tattr = state.get_attrib(TextureAttrib.get_class_type())
except Exception:
try:
if state.hasAttrib(TextureAttrib.getClassType()):
tattr = state.getAttrib(TextureAttrib.getClassType())
except Exception:
tattr = None
if not tattr:
continue
try:
num_on = tattr.get_num_on_stages()
get_stage = tattr.get_on_stage
get_tex = tattr.get_on_texture
except Exception:
try:
num_on = tattr.getNumOnStages()
get_stage = tattr.getOnStage
get_tex = tattr.getOnTexture
except Exception:
continue
for si in range(int(num_on)):
try:
stage = get_stage(si)
tex = get_tex(stage)
except Exception:
continue
if not stage or not tex:
continue
if self._texture_is_valid(tex):
continue
basename = self._extract_texture_basename(tex)
replacement = texture_index.get(basename.lower()) if basename else None
if replacement:
try:
new_tex = self._load_texture_from_path(replacement)
if new_tex:
try:
node.set_texture(stage, new_tex, 100000)
except Exception:
node.setTexture(stage, new_tex, 100000)
fixed += 1
continue
except Exception:
pass
try:
try:
node.clear_texture(stage)
except Exception:
node.clearTexture(stage)
cleared += 1
except Exception:
pass
if fixed or cleared:
print(f"[SSBOEditor] Texture repair: fixed={fixed}, cleared_missing={cleared}")
self._apply_nonblack_material_fallback(model_np)
def _texture_is_valid(self, tex):
if not tex:
return False
tex_path = self._extract_texture_os_path(tex)
if tex_path and os.path.exists(tex_path):
return True
if tex_path:
# Some valid textures use Panda VFS virtual paths; keep them RAM-checkable.
path_norm = tex_path.replace("\\", "/")
if path_norm.startswith("/$$") or path_norm.startswith("$$"):
pass
else:
# File-backed texture with missing source path should be considered invalid,
# even when Panda keeps a tiny fallback image in RAM.
return False
try:
if tex.has_ram_image():
return tex.get_ram_image_size() > 0
except Exception:
try:
if tex.hasRamImage():
return tex.getRamImageSize() > 0
except Exception:
pass
return False
def _extract_texture_os_path(self, tex):
tex_path = ""
try:
if tex.has_fullpath():
fullpath = tex.get_fullpath()
try:
tex_path = fullpath.to_os_specific()
except Exception:
try:
tex_path = fullpath.toOsSpecific()
except Exception:
tex_path = str(fullpath)
except Exception:
try:
if tex.hasFullpath():
fullpath = tex.getFullpath()
try:
tex_path = fullpath.toOsSpecific()
except Exception:
tex_path = str(fullpath)
except Exception:
tex_path = ""
tex_path = str(tex_path or "").strip()
if not tex_path:
return ""
tex_path = os.path.normpath(tex_path)
if os.path.exists(tex_path):
return tex_path
# Convert Panda internal drive path (/d/foo/bar) to Windows path if needed.
if len(tex_path) >= 3 and tex_path[0] in ("/", "\\") and tex_path[1].isalpha() and tex_path[2] in ("/", "\\"):
drive_path = f"{tex_path[1]}:{tex_path[2:]}"
drive_path = os.path.normpath(drive_path)
if os.path.exists(drive_path):
return drive_path
return tex_path
def _extract_texture_basename(self, tex):
tex_path = self._extract_texture_os_path(tex)
if tex_path:
return os.path.basename(tex_path.replace("\\", "/"))
try:
name = tex.get_name()
except Exception:
try:
name = tex.getName()
except Exception:
name = ""
return os.path.basename(str(name).replace("\\", "/"))
def _get_white_fallback_texture(self):
tex = getattr(self, "_ssbo_white_fallback_tex", None)
if tex:
return tex
try:
img = PNMImage(2, 2, 4)
img.fill(1.0, 1.0, 1.0)
img.alpha_fill(1.0)
tex = Texture("ssbo_white_fallback")
tex.load(img)
tex.set_minfilter(Texture.FT_nearest)
tex.set_magfilter(Texture.FT_nearest)
self._ssbo_white_fallback_tex = tex
return tex
except Exception:
return None
def _node_has_valid_texture(self, node):
if not node or node.is_empty():
return False
try:
stages = node.find_all_texture_stages()
stage_count = stages.get_num_texture_stages()
stage_at = stages.get_texture_stage
except Exception:
try:
stages = node.findAllTextureStages()
stage_count = stages.getNumTextureStages()
stage_at = stages.getTextureStage
except Exception:
return False
for i in range(stage_count):
stage = stage_at(i)
if not stage:
continue
try:
tex = node.get_texture(stage)
except Exception:
tex = node.getTexture(stage)
if self._texture_is_valid(tex):
return True
# GeomState TextureAttrib bindings can hold the effective textures.
try:
gnode = node.node()
get_num_geoms = getattr(gnode, "get_num_geoms", None) or getattr(gnode, "getNumGeoms", None)
get_geom_state = getattr(gnode, "get_geom_state", None) or getattr(gnode, "getGeomState", None)
geom_count = int(get_num_geoms()) if callable(get_num_geoms) else 0
except Exception:
geom_count = 0
for gi in range(geom_count):
try:
state = get_geom_state(gi)
except Exception:
continue
if state is None:
continue
tattr = None
try:
if state.has_attrib(TextureAttrib.get_class_type()):
tattr = state.get_attrib(TextureAttrib.get_class_type())
except Exception:
try:
if state.hasAttrib(TextureAttrib.getClassType()):
tattr = state.getAttrib(TextureAttrib.getClassType())
except Exception:
tattr = None
if not tattr:
continue
try:
num_on = tattr.get_num_on_stages()
get_stage = tattr.get_on_stage
get_tex = tattr.get_on_texture
except Exception:
try:
num_on = tattr.getNumOnStages()
get_stage = tattr.getOnStage
get_tex = tattr.getOnTexture
except Exception:
continue
for si in range(int(num_on)):
try:
stage = get_stage(si)
tex = get_tex(stage)
except Exception:
continue
if self._texture_is_valid(tex):
return True
return False
def _is_node_material_dark(self, node):
"""Heuristic: detect near-black material/color state."""
if not node or node.is_empty():
return False
try:
state = node.get_state()
except Exception:
try:
state = node.getState()
except Exception:
return True
def _vec_dark(v):
try:
return max(float(v[0]), float(v[1]), float(v[2])) < 0.08
except Exception:
try:
return max(float(v.x), float(v.y), float(v.z)) < 0.08
except Exception:
return False
# Material color
mat = None
try:
if state.has_attrib(MaterialAttrib.get_class_type()):
mat_attr = state.get_attrib(MaterialAttrib.get_class_type())
mat = mat_attr.get_material()
except Exception:
try:
if state.hasAttrib(MaterialAttrib.getClassType()):
mat_attr = state.getAttrib(MaterialAttrib.getClassType())
mat = mat_attr.getMaterial()
except Exception:
mat = None
if mat:
for has_name, get_name in (
("has_base_color", "get_base_color"),
("has_diffuse", "get_diffuse"),
("hasBaseColor", "getBaseColor"),
("hasDiffuse", "getDiffuse"),
):
has_fn = getattr(mat, has_name, None)
get_fn = getattr(mat, get_name, None)
if callable(has_fn) and callable(get_fn):
try:
if has_fn():
return _vec_dark(get_fn())
except Exception:
continue
return False
# ColorAttrib fallback
try:
if state.has_attrib(ColorAttrib.get_class_type()):
color_attr = state.get_attrib(ColorAttrib.get_class_type())
if not color_attr.is_off():
return _vec_dark(color_attr.get_color())
except Exception:
try:
if state.hasAttrib(ColorAttrib.getClassType()):
color_attr = state.getAttrib(ColorAttrib.getClassType())
if not color_attr.isOff():
return _vec_dark(color_attr.getColor())
except Exception:
pass
return True
def _apply_nonblack_material_fallback(self, model_np):
"""If a geom has no texture and is effectively black, apply a neutral material."""
if not model_np or model_np.is_empty():
return
neutral = Material()
neutral.set_base_color((0.75, 0.75, 0.75, 1.0)) if hasattr(neutral, "set_base_color") else neutral.setBaseColor((0.75, 0.75, 0.75, 1.0))
neutral.set_diffuse((0.75, 0.75, 0.75, 1.0)) if hasattr(neutral, "set_diffuse") else neutral.setDiffuse((0.75, 0.75, 0.75, 1.0))
neutral.set_ambient((0.22, 0.22, 0.22, 1.0)) if hasattr(neutral, "set_ambient") else neutral.setAmbient((0.22, 0.22, 0.22, 1.0))
neutral.set_specular((0.1, 0.1, 0.1, 1.0)) if hasattr(neutral, "set_specular") else neutral.setSpecular((0.1, 0.1, 0.1, 1.0))
neutral.set_shininess(8.0) if hasattr(neutral, "set_shininess") else neutral.setShininess(8.0)
patched = 0
for geom_np in model_np.find_all_matches("**/+GeomNode"):
if self._node_has_valid_texture(geom_np):
continue
if not self._is_node_material_dark(geom_np):
continue
try:
try:
geom_np.set_material(neutral, 1)
except Exception:
geom_np.setMaterial(neutral, 1)
try:
geom_np.set_color_scale(1.0, 1.0, 1.0, 1.0)
except Exception:
geom_np.setColorScale(1.0, 1.0, 1.0, 1.0)
patched += 1
except Exception:
continue
if patched:
print(f"[SSBOEditor] Applied non-black fallback material to {patched} geom nodes.")
# No custom effect needed — RP default rendering for maximum FPS
def _inject_ssbo_into_shadow_state(self, effect_path):
"""Inject SSBO inputs into RP shadow tag state"""
try:
if not hasattr(self.rp.tag_mgr, 'containers'): return
shadow_container = self.rp.tag_mgr.containers.get("shadow")
if not shadow_container: return
tag_value = self.model.get_tag(shadow_container.tag_name)
if not tag_value: return
effect = Effect.load(effect_path, {})
if effect is None: return
shadow_shader = effect.get_shader_obj("shadow")
if shadow_shader is None: return
# Since inputs are now on Nodes (Chunks), we just need to ensure the shader is applied.
# extra_inputs is no longer needed if the inputs are on the nodes themselves?
# Wait, RP might override state.
# But specific shader inputs on NodePath have priority over State inputs usually?
# Let's try applying without extra inputs first.
self.rp.tag_mgr.apply_state(
"shadow", self.model, shadow_shader,
tag_value, 65)
print(f"[SSBO Shadow] Re-applied shadow state (tag='{tag_value}')")
except Exception as e:
print(f"[SSBO Shadow] Error injecting shadow state: {e}")
def setup_gpu_picking(self):
"""Setup GPU Picking (Basic implementation)"""
# ... (Buffer setup code remains same) ...
win_props = WindowProperties()
win_props.set_size(1, 1)
fb_props = FrameBufferProperties()
fb_props.set_rgba_bits(8, 8, 8, 8)
fb_props.set_depth_bits(16)
self.pick_buffer = self.base.graphicsEngine.make_output(
self.base.pipe, "pick_buffer", -100,
fb_props, win_props,
GraphicsPipe.BF_refuse_window,
self.base.win.get_gsg(), self.base.win
)
if not self.pick_buffer:
print("[GPU Picking] Failed to create buffer!")
return
self.pick_texture = Texture()
self.pick_texture.set_minfilter(Texture.FT_nearest)
self.pick_texture.set_magfilter(Texture.FT_nearest)
self.pick_buffer.add_render_texture(self.pick_texture, GraphicsOutput.RTM_copy_ram)
self.pick_cam = Camera("pick_camera")
self.pick_cam.set_camera_mask(self.pick_mask)
self.pick_cam_np = self.base.cam.attach_new_node(self.pick_cam)
self.pick_lens = self.base.camLens.make_copy()
self.pick_cam.set_lens(self.pick_lens)
dr = self.pick_buffer.make_display_region()
dr.set_camera(self.pick_cam_np)
# Load pick shader
current_dir = os.path.dirname(os.path.abspath(__file__))
pick_vert_path = os.path.join(current_dir, "shaders", "pick_id.vert")
pick_frag_path = os.path.join(current_dir, "shaders", "pick_id.frag")
try:
# Read shader source directly from OS filesystem to avoid
# Panda3D VFS case-mismatch issues on Windows.
with open(pick_vert_path, 'r', encoding='utf-8') as f:
vert_src = f.read().replace('\r', '')
with open(pick_frag_path, 'r', encoding='utf-8') as f:
frag_src = f.read().replace('\r', '')
pick_shader = Shader.make(Shader.SL_GLSL, vert_src, frag_src)
pick_scene = getattr(self.controller, "pick_model", None) or self.model
if pick_scene and not pick_scene.is_empty():
pick_scene.show(self.pick_mask)
self.pick_cam.set_scene(pick_scene or self._empty_pick_scene)
initial_state = NodePath("initial")
initial_state.set_shader(pick_shader, 100)
# Remove global SSBO input, Chunks have their own inputs
# initial_state.set_shader_input("transforms", ssbo)
self.pick_cam.set_initial_state(initial_state.get_state())
except Exception as e:
print(f"[GPU Picking] Warning: pick shaders failed to load: {e}")
print("Picking disabled.")
return
self.pick_buffer.set_active(False)
self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0))
self.pick_buffer.set_clear_color_active(True)
def _sync_pick_model_transform(self):
"""Sync pick-scene clone to current source model world transform."""
if not self.controller or not self.model:
return
pick_model = getattr(self.controller, "pick_model", None)
if pick_model is None:
return
try:
if pick_model.isEmpty():
return
except Exception:
try:
if pick_model.is_empty():
return
except Exception:
pass
try:
if hasattr(self.controller, "get_model_world_mat"):
world_mat = self.controller.get_model_world_mat()
else:
world_mat = LMatrix4f(self.model.getNetTransform().getMat())
try:
pick_model.set_mat(world_mat)
except Exception:
pick_model.setMat(world_mat)
except Exception:
pass
def _refresh_ssbo_proxy_center(self):
"""Update proxy center when source model transform changes."""
if self._ssbo_transform_active:
return
if not self.controller or not self._ssbo_selected_local_indices:
return
if self._ssbo_gizmo_proxy is None:
return
try:
if self._ssbo_gizmo_proxy.isEmpty():
return
except Exception:
return
try:
center = self.controller.get_selection_center(self._ssbo_selected_local_indices)
self._ssbo_gizmo_proxy.set_pos(center)
except Exception:
pass
def _is_model_attached(self):
"""Whether the SSBO render root is still attached to scene graph."""
if not self.model or self.model.is_empty():
return False
parent = self.model.get_parent()
return bool(parent) and not parent.is_empty()
def _sync_pick_scene_binding(self):
"""Switch pick camera scene based on current model attachment state."""
if not hasattr(self, "pick_cam") or not self.pick_cam:
return
if self._is_model_attached() and self.controller:
target_scene = getattr(self.controller, "pick_model", None) or self.model
else:
target_scene = self._empty_pick_scene
if not target_scene:
target_scene = self._empty_pick_scene
if self.pick_cam.get_scene() != target_scene:
self.pick_cam.set_scene(target_scene)
def pick_object(self, mx, my):
if (not self.pick_buffer or not self.pick_texture or not self.pick_lens or
not self.controller or not self.model):
return False
self._sync_pick_model_transform()
try:
self.pick_lens.setAspectRatio(self.base.camLens.getAspectRatio())
except Exception:
pass
self.pick_lens.set_fov(0.1)
self.pick_lens.set_film_offset(0, 0)
self.pick_cam.set_lens(self.pick_lens)
near_point = Point3()
far_point = Point3()
self.base.camLens.extrude(Point2(mx, my), near_point, far_point)
self.pick_cam_np.set_pos(0, 0, 0)
self.pick_cam_np.look_at(far_point)
# Ensure pick transforms are up-to-date before rendering the pick buffer.
# The per-frame sync task may not have run yet for this frame.
self._sync_pick_transforms()
self.pick_buffer.set_active(True)
self.base.graphicsEngine.render_frame()
self.pick_buffer.set_active(False)
ram_image = self.pick_texture.get_ram_image_as("RGBA")
if ram_image:
data = memoryview(ram_image)
if len(data) >= 4:
r, g, b, a = data[0], data[1], data[2], data[3]
if a > 0 and b == 0:
hit_id = r + (g << 8)
node_key = self.controller.id_to_name.get(hit_id)
if node_key:
print(f"[Pick] Hit: ID={hit_id} -> {node_key}")
self.select_node(node_key)
return
self.clear_selection()
def on_mouse_click(self):
io = imgui.get_io()
if io.want_capture_mouse: return
# Skip SSBO picking when user is interacting with the TransformGizmo,
# otherwise pick_object would clear the selection and detach the gizmo
# before the gizmo's own mouse handler fires.
if self._transform_gizmo and self._transform_gizmo.is_hovering:
return
if self.base.mouseWatcherNode.has_mouse():
self._sync_pick_model_transform()
self._refresh_ssbo_proxy_center()
mpos = self.base.mouseWatcherNode.get_mouse()
self.pick_object(mpos.x, mpos.y)
def toggle_debug(self):
self.debug_mode = not self.debug_mode
def bind_transform_gizmo(self, gizmo):
"""Bind a TransformGizmo so it follows SSBO selection."""
self._transform_gizmo = gizmo
def _start_pick_sync_task(self):
"""Start a per-frame task that syncs pick transforms for selected objects."""
self.base.task_mgr.remove("ssbo_pick_sync")
self.base.task_mgr.add(self._pick_sync_task, "ssbo_pick_sync")
def _stop_pick_sync_task(self):
"""Stop the per-frame pick sync task."""
self.base.task_mgr.remove("ssbo_pick_sync")
def _pick_sync_task(self, task):
"""Per-frame: keep pick model transforms in sync with render model."""
self._sync_pick_transforms()
return task.cont
def _reset_pick_sync_cache(self):
self._last_group_sync_mat = None
self._last_single_sync_gid = None
self._last_single_sync_mat = None
def _matrices_close(self, a, b, eps=1e-5):
"""Small helper for robust matrix change detection."""
for r in range(4):
ra = a.get_row(r)
rb = b.get_row(r)
if (abs(ra[0] - rb[0]) > eps or
abs(ra[1] - rb[1]) > eps or
abs(ra[2] - rb[2]) > eps or
abs(ra[3] - rb[3]) > eps):
return False
return True
def _sync_pick_root_transform(self):
"""
Keep pick root aligned with the render root transform.
This covers transforms applied to the whole imported model
(for example, moving box.glb from scene hierarchy).
"""
if not self.controller or not self.model or not self._is_model_attached():
return
pick_root = getattr(self.controller, "pick_model", None)
if not pick_root:
return
if self.model.is_empty() or pick_root.is_empty():
return
pick_root.set_mat(self.base.render, self.model.get_mat(self.base.render))
def _sync_pick_transforms(self):
"""Sync pick model transforms to match render model transforms."""
if not self.controller:
return
self._sync_pick_root_transform()
if not self.selected_ids:
return
# Group selection can contain thousands of objects.
# Only resync when proxy transform has changed.
proxy = getattr(self, "_group_proxy", None)
if proxy and not proxy.is_empty() and len(self.selected_ids) > 1:
proxy_world = proxy.get_mat(self.base.render)
if self._last_group_sync_mat and self._matrices_close(proxy_world, self._last_group_sync_mat):
return
self._last_group_sync_mat = LMatrix4f(proxy_world)
else:
self._last_group_sync_mat = None
if len(self.selected_ids) == 1:
gid = self.selected_ids[0]
obj_np = self.controller.id_to_object_np.get(gid)
pick_np = self.controller.id_to_pick_np.get(gid)
if not (obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty()):
return
obj_world_mat = obj_np.get_mat(self.base.render)
if (
self._last_single_sync_gid == gid and
self._last_single_sync_mat and
self._matrices_close(obj_world_mat, self._last_single_sync_mat)
):
return
self._last_single_sync_gid = gid
self._last_single_sync_mat = LMatrix4f(obj_world_mat)
pick_world_mat = pick_np.get_mat(self.base.render)
if not self._matrices_close(obj_world_mat, pick_world_mat):
pick_np.set_mat(self.base.render, obj_world_mat)
chunk_id = self.controller.id_to_chunk.get(gid)
if chunk_id is not None and chunk_id in self.controller.chunks:
self.controller.chunks[chunk_id]["dirty"] = True
return
self._last_single_sync_gid = None
self._last_single_sync_mat = None
for gid in self.selected_ids:
obj_np = self.controller.id_to_object_np.get(gid)
pick_np = self.controller.id_to_pick_np.get(gid)
if obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty():
obj_world_mat = obj_np.get_mat(self.base.render)
pick_world_mat = pick_np.get_mat(self.base.render)
if not self._matrices_close(obj_world_mat, pick_world_mat):
# Sync by world transform so this stays correct even when
# the model root itself has been moved in scene hierarchy.
pick_np.set_mat(self.base.render, obj_world_mat)
chunk_id = self.controller.id_to_chunk.get(gid)
if chunk_id is not None and chunk_id in self.controller.chunks:
self.controller.chunks[chunk_id]["dirty"] = True
def sync_scene_nodes_to_pick(self, nodes):
"""Sync transformed scene nodes to both pick data and visible static chunks."""
if not self.controller:
return
self._sync_pick_scene_binding()
self._sync_pick_root_transform()
valid_nodes = []
for node in nodes or []:
if not self._node_is_valid(node):
continue
duplicate = False
for existing in valid_nodes:
try:
if existing == node:
duplicate = True
break
except Exception:
pass
if not duplicate:
valid_nodes.append(node)
if not valid_nodes:
self._reset_pick_sync_cache()
return
affected_chunks = set()
for gid, obj_np in self.controller.id_to_object_np.items():
if not self._node_is_valid(obj_np):
continue
matched = False
for target in valid_nodes:
try:
if obj_np == target:
matched = True
break
except Exception:
pass
if not matched:
continue
is_attached = False
try:
is_attached = bool(obj_np.has_parent())
except Exception:
try:
is_attached = bool(obj_np.hasParent())
except Exception:
is_attached = False
is_visible = False
if is_attached:
try:
is_visible = not obj_np.is_hidden()
except Exception:
try:
is_visible = not obj_np.isHidden()
except Exception:
is_visible = True
pick_np = self.controller.id_to_pick_np.get(gid)
if pick_np and not pick_np.is_empty():
if is_visible:
try:
pick_np.show()
except Exception:
pass
try:
pick_np.set_mat(self.base.render, obj_np.get_mat(self.base.render))
except Exception:
try:
pick_np.setMat(self.base.render, obj_np.getMat(self.base.render))
except Exception:
pass
else:
try:
pick_np.hide()
except Exception:
pass
chunk_id = self.controller.id_to_chunk.get(gid)
if chunk_id is not None and chunk_id in self.controller.chunks:
self.controller.chunks[chunk_id]["dirty"] = True
affected_chunks.add(chunk_id)
for chunk_id in affected_chunks:
chunk = self.controller.chunks.get(chunk_id)
if not chunk or chunk.get("dynamic_enabled"):
continue
try:
self.controller._rebuild_static_chunk(chunk_id)
except Exception:
pass
self._reset_pick_sync_cache()
def _update_outline_for_selection(self):
if not self._outline_manager:
return
if not self.controller or not self.selected_ids:
self._outline_manager.clear()
return
is_root_selection = (
self.controller and
self.selected_name == getattr(self.controller, "tree_root_key", None)
)
if is_root_selection:
self._outline_manager.clear()
return
targets = []
target_limit = max(1, int(getattr(self._outline_manager, "max_targets", 64)))
for gid in self.selected_ids:
obj_np = self.controller.id_to_object_np.get(gid)
if obj_np and not obj_np.is_empty():
targets.append(obj_np)
if len(targets) >= target_limit:
break
self._outline_manager.set_targets(targets)
def _node_is_valid(self, node):
if not node:
return False
try:
return not node.is_empty()
except Exception:
try:
return not node.isEmpty()
except Exception:
return False
def has_active_selection(self):
return bool(self.controller and self.selected_name is not None)
def _is_root_selection(self):
return bool(
self.controller and
self.selected_name == getattr(self.controller, "tree_root_key", None)
)
def get_selection_scene_node(self):
"""Return a stable scene node for editor features that need one."""
if not self.controller or self.selected_name is None:
return None
if self._is_root_selection():
return self.model if self._node_is_valid(self.model) else None
if len(self.selected_ids) > 1:
proxy = getattr(self, "_group_proxy", None)
if proxy and self._node_is_valid(proxy):
# Apply the original first node's material/tags to proxy transparently?
# The property panel gets the proxy node for transform edits.
return proxy
if len(self.selected_ids) >= 1:
obj_np = self.controller.id_to_object_np.get(self.selected_ids[0])
if self._node_is_valid(obj_np):
return obj_np
return None
def get_selection_summary(self):
if not self.controller or self.selected_name is None:
return None
return {
"key": self.selected_name,
"display_name": self.controller.display_names.get(self.selected_name, self.selected_name),
"object_count": len(self.selected_ids),
"is_root": self._is_root_selection(),
"is_group": len(self.selected_ids) > 1 and not self._is_root_selection(),
}
def _sync_editor_selection_reference(self, node):
selection = getattr(self.base, "selection", None)
if not selection:
return
selection.selectedNode = node
selection.selectedObject = node
def _clear_editor_selection_visuals(self):
selection = getattr(self.base, "selection", None)
if not selection:
return
try:
selection.clearSelectionBox()
except Exception:
pass
try:
selection._updateSelectionOutline(None)
except Exception:
pass
try:
selection.clearGizmo()
except Exception:
pass
def _find_tree_key_for_scene_node(self, node):
if not self.controller or not self._node_is_valid(node):
return None
if self.model and node == self.model:
return getattr(self.controller, "tree_root_key", None)
for gid, obj_np in self.controller.id_to_object_np.items():
if obj_np == node:
return self.controller.id_to_name.get(gid)
return None
def sync_scene_selection(self, node):
"""Mirror scene-tree selection back into SSBO state, or clear stale SSBO state."""
if not self.controller:
return
target_key = self._find_tree_key_for_scene_node(node)
if target_key:
self.select_node(target_key, sync_world_selection=False)
return
if self.has_active_selection():
self.clear_selection(sync_world_selection=False)
def clear_selection(self, sync_world_selection=True):
self._stop_pick_sync_task()
self._reset_pick_sync_cache()
self._cleanup_group_proxy()
self.selected_name = None
self.selected_ids = []
if self._outline_manager:
self._outline_manager.clear()
if self.controller:
self.controller.set_active_ids([])
if self._transform_gizmo:
self._transform_gizmo.detach()
if sync_world_selection:
self._clear_editor_selection_visuals()
self._sync_editor_selection_reference(None)
def on_model_deleted(self, deleted_node):
"""Called by app deletion flow when SSBO root model is deleted."""
if not deleted_node or deleted_node.is_empty() or not self.model:
return
if deleted_node != self.model:
return
self.clear_selection()
self._sync_pick_scene_binding()
def reset_scene_state(self):
"""Remove the current SSBO model/controller state before loading another scene."""
self._clear_runtime_state(preserve_source_models=False)
def _cleanup_group_proxy(self):
"""Reparent objects back to their chunk and remove the group proxy."""
proxy = getattr(self, '_group_proxy', None)
if not proxy:
return
originals = getattr(self, '_group_original_parents', {})
# Sync pick transforms and mark chunks dirty before reparenting
for gid in originals:
obj_np = self.controller.id_to_object_np.get(gid)
pick_np = self.controller.id_to_pick_np.get(gid)
if obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty():
pick_np.set_mat(self.base.render, obj_np.get_mat(self.base.render))
chunk_id = self.controller.id_to_chunk.get(gid)
if chunk_id is not None and chunk_id in self.controller.chunks:
self.controller.chunks[chunk_id]["dirty"] = True
# Reparent objects back to their original chunk parents
for gid, parent_np in originals.items():
obj_np = self.controller.id_to_object_np.get(gid)
if obj_np and not obj_np.is_empty() and parent_np and not parent_np.is_empty():
obj_np.wrt_reparent_to(parent_np)
if not proxy.is_empty():
proxy.remove_node()
self._group_proxy = None
self._group_original_parents = {}
def update_selection_mask(self):
pass # No selection mask texture needed without custom shader
def select_node(self, key, sync_world_selection=True):
# Clean up previous group proxy before changing selection
self._cleanup_group_proxy()
self._reset_pick_sync_cache()
self.selected_name = key
self.selected_ids = self.controller.name_to_ids.get(key, [])
is_root_selection = (
self.controller and
key == getattr(self.controller, "tree_root_key", None)
)
if sync_world_selection:
self._clear_editor_selection_visuals()
self._sync_editor_selection_reference(self.get_selection_scene_node())
# Root selection should stay lightweight:
# keep static chunks active and transform the model root directly.
if is_root_selection:
self.controller.set_active_ids([])
if self._outline_manager:
self._outline_manager.clear()
if self._transform_gizmo and self.model and not self.model.is_empty():
self._transform_gizmo.attach(self.model)
else:
if self._transform_gizmo:
self._transform_gizmo.detach()
self._stop_pick_sync_task()
return
self.controller.set_active_ids(self.selected_ids)
self._update_outline_for_selection()
if not self._transform_gizmo or not self.selected_ids:
if self._transform_gizmo:
self._transform_gizmo.detach()
return
if len(self.selected_ids) == 1:
# Single object: attach gizmo directly
obj_np = self.controller.id_to_object_np.get(self.selected_ids[0])
if obj_np and not obj_np.is_empty():
self._transform_gizmo.attach(obj_np)
self._start_pick_sync_task()
return
# Multiple objects (parent node): create a group proxy so all children
# follow the gizmo transform together.
from panda3d.core import Vec3
proxy = self.base.render.attach_new_node("ssbo_group_proxy")
try:
proxy.set_name(self.controller.display_names.get(key, "ssbo_group_proxy"))
proxy.setTag("is_ssbo_proxy", "1")
proxy.setTag("ssbo_selection_key", str(key))
except Exception:
pass
center = Vec3(0, 0, 0)
valid = []
for gid in self.selected_ids:
obj_np = self.controller.id_to_object_np.get(gid)
if obj_np and not obj_np.is_empty():
center += obj_np.get_pos(self.base.render)
valid.append(gid)
if not valid:
proxy.remove_node()
return
center /= len(valid)
proxy.set_pos(self.base.render, center)
self._group_proxy = proxy
self._group_original_parents = {}
for gid in valid:
obj_np = self.controller.id_to_object_np[gid]
self._group_original_parents[gid] = obj_np.get_parent()
obj_np.wrt_reparent_to(proxy)
self._transform_gizmo.attach(proxy)
# For huge groups, avoid per-frame full sync; we still sync on demand
# right before picking via pick_object().
if len(valid) <= self._pick_sync_bg_limit:
self._start_pick_sync_task()
else:
self._stop_pick_sync_task()
def _rebuild_filtered_tree_rows(self):
"""
Build a flattened tree-row list with depth info for rendering in ImGui,
while preserving source-model parent/child hierarchy.
"""
self.filtered_nodes = []
if not self.controller or not self.controller.tree_root_key:
return
search_lower = self.search_text.strip().lower()
def walk(key, depth):
node = self.controller.tree_nodes.get(key)
if not node:
return False, []
# Skip redundant wrapper nodes (e.g. ROOT under model file root),
# while preserving child hierarchy and selection mapping.
if self.controller.should_hide_tree_node(key):
merged_rows = []
merged_visible = False
for child_key in node["children"]:
visible, rows = walk(child_key, depth)
if visible:
merged_visible = True
merged_rows.extend(rows)
return merged_visible, merged_rows
display = self.controller.display_names.get(key, key)
obj_count = len(self.controller.name_to_ids.get(key, []))
name_match = (not search_lower) or (search_lower in display.lower())
child_rows = []
child_match = False
for child_key in node["children"]:
visible, rows = walk(child_key, depth + 1)
if visible:
child_match = True
child_rows.extend(rows)
visible = (not search_lower) or name_match or child_match
if not visible:
return False, []
row = (key, depth, display, obj_count)
return True, [row] + child_rows
_, rows = walk(self.controller.tree_root_key, 0)
self.filtered_nodes = rows
def focus_on_selected(self):
if self.selected_name and self.selected_ids:
first_id = self.selected_ids[0]
pos = self.controller.get_world_pos(first_id)
dist = 100
self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5)
self.base.camera.look_at(pos)
def draw_imgui(self):
if not self.controller: return
imgui.set_next_window_pos((10, 10), imgui.Cond_.first_use_ever)
imgui.set_next_window_size((350, 600), imgui.Cond_.first_use_ever)
expanded, opened = imgui.begin("Scene Tree (Component)")
if expanded:
imgui.text(f"FPS: {globalClock.getAverageFrameRate():.1f}")
imgui.separator()
changed, self.search_text = imgui.input_text("Search", self.search_text, 256)
if imgui.begin_child("ObjectList", (0, 380), child_flags=imgui.ChildFlags_.borders):
if self.search_text != self.last_search_text or not self.filtered_nodes:
self.last_search_text = self.search_text
self._rebuild_filtered_tree_rows()
count = len(self.filtered_nodes)
clipper = imgui.ListClipper()
clipper.begin(count)
while clipper.step():
for i in range(clipper.display_start, clipper.display_end):
key, depth, display, geom_count = self.filtered_nodes[i]
indent = " " * depth
label = f"{indent}{display} ({geom_count})##{key}"
is_selected = (key == self.selected_name)
if imgui.selectable(label, is_selected)[0]:
self.select_node(key)
imgui.end_child()
imgui.separator()
if self.selected_name:
selected_display = self.controller.display_names.get(self.selected_name, self.selected_name)
imgui.text_colored((1, 0.8, 0.2, 1), f"Selected: {selected_display}")
if imgui.button("Focus (F)"): self.focus_on_selected()
imgui.end()
# swap_transforms_task removed - motion blur disabled for performance
def update_task(self, task):
dt = globalClock.getDt()
io = imgui.get_io()
self._sync_pick_model_transform()
self._refresh_ssbo_proxy_center()
self._sync_pick_scene_binding()
# Scene-hierarchy transforms may move the whole SSBO model root; keep pick root in sync.
self._sync_pick_root_transform()
if io.want_capture_keyboard: return task.cont
if self.selected_ids and self.controller:
speed = 50 * dt
acc = Vec3(0, 0, 0)
if self.keys.get('arrow_up'): acc.z += speed
if self.keys.get('arrow_down'): acc.z -= speed
if self.keys.get('arrow_left'): acc.x -= speed
if self.keys.get('arrow_right'): acc.x += speed
if self.keys.get('z'): acc.y += speed
if self.keys.get('x'): acc.y -= speed
if acc.length_squared() > 0:
is_root_selection = (
self.selected_name == getattr(self.controller, "tree_root_key", None)
)
if is_root_selection and self.model and not self.model.is_empty():
next_pos = self.model.get_pos() + acc
if hasattr(self.model, "set_fluid_pos"):
self.model.set_fluid_pos(next_pos)
else:
self.model.set_pos(next_pos)
self._sync_pick_root_transform()
else:
for idx in self.selected_ids:
self.controller.move_object(idx, acc)
return task.cont