diff --git a/RenderPipelineFile/config/plugins.yaml b/RenderPipelineFile/config/plugins.yaml index 1bb5b1c4..dfce42fa 100644 --- a/RenderPipelineFile/config/plugins.yaml +++ b/RenderPipelineFile/config/plugins.yaml @@ -13,6 +13,7 @@ enabled: - scattering - skin_shading - sky_ao + - selection_outline - smaa - ssr # - clouds diff --git a/RenderPipelineFile/config/stages.yaml b/RenderPipelineFile/config/stages.yaml index 7366794f..d20988ff 100644 --- a/RenderPipelineFile/config/stages.yaml +++ b/RenderPipelineFile/config/stages.yaml @@ -63,5 +63,6 @@ global_stage_order: # Finishing stages, do not insert anything below - UpscaleStage + - SelectionOutlineStage - FinalStage - UpdatePreviousPipesStage diff --git a/RenderPipelineFile/rpplugins/selection_outline/__init__.py b/RenderPipelineFile/rpplugins/selection_outline/__init__.py new file mode 100644 index 00000000..59812f04 --- /dev/null +++ b/RenderPipelineFile/rpplugins/selection_outline/__init__.py @@ -0,0 +1,2 @@ +"""Selection outline plugin package.""" + diff --git a/RenderPipelineFile/rpplugins/selection_outline/config.yaml b/RenderPipelineFile/rpplugins/selection_outline/config.yaml new file mode 100644 index 00000000..5a7ab260 --- /dev/null +++ b/RenderPipelineFile/rpplugins/selection_outline/config.yaml @@ -0,0 +1,3 @@ +settings: +daytime_settings: + diff --git a/RenderPipelineFile/rpplugins/selection_outline/plugin.py b/RenderPipelineFile/rpplugins/selection_outline/plugin.py new file mode 100644 index 00000000..b8a4ffc4 --- /dev/null +++ b/RenderPipelineFile/rpplugins/selection_outline/plugin.py @@ -0,0 +1,15 @@ +from rpcore.pluginbase.base_plugin import BasePlugin + +from .selection_outline_stage import SelectionOutlineStage + + +class Plugin(BasePlugin): + + name = "Selection Outline" + author = "EG Team" + description = "Adds Unity-style selected-object outline as a post-process stage." + version = "1.0" + + def on_stage_setup(self): + self.stage = self.create_stage(SelectionOutlineStage) + diff --git a/RenderPipelineFile/rpplugins/selection_outline/selection_outline_stage.py b/RenderPipelineFile/rpplugins/selection_outline/selection_outline_stage.py new file mode 100644 index 00000000..c0f25401 --- /dev/null +++ b/RenderPipelineFile/rpplugins/selection_outline/selection_outline_stage.py @@ -0,0 +1,68 @@ +from panda3d.core import PNMImage, SamplerState, Texture, Vec4 + +from rpcore.render_stage import RenderStage + + +class SelectionOutlineStage(RenderStage): + + required_pipes = ["ShadedScene"] + + def __init__(self, pipeline): + RenderStage.__init__(self, pipeline) + self._enabled = False + self._outline_color = Vec4(1.0, 0.55, 0.0, 1.0) + self._outline_width = 2.0 + self._fill_alpha = 0.0 + self._mask_tex = self._make_default_mask() + + @property + def produced_pipes(self): + return {"ShadedScene": self.target.color_tex} + + def create(self): + self.target = self.create_target("SelectionOutline") + self.target.add_color_attachment(bits=16) + self.target.prepare_buffer() + self._apply_inputs() + + def reload_shaders(self): + self.target.shader = self.load_plugin_shader("selection_outline.frag.glsl") + self._apply_inputs() + + def set_mask_texture(self, mask_texture): + self._mask_tex = mask_texture if mask_texture else self._make_default_mask() + self._apply_inputs() + + def set_enabled_outline(self, enabled): + self._enabled = bool(enabled) + self._apply_inputs() + + def set_outline_style(self, color=None, width_px=None, fill_alpha=None): + if color is not None: + self._outline_color = Vec4(color) + if width_px is not None: + self._outline_width = max(0.0, float(width_px)) + if fill_alpha is not None: + self._fill_alpha = max(0.0, min(1.0, float(fill_alpha))) + self._apply_inputs() + + def _apply_inputs(self): + enabled_val = 1.0 if self._enabled else 0.0 + self.target.set_shader_input("SelectionMaskTex", self._mask_tex) + self.target.set_shader_input("SelectionOutlineEnabled", enabled_val) + self.target.set_shader_input("SelectionOutlineColor", self._outline_color) + self.target.set_shader_input("SelectionOutlineWidth", self._outline_width) + self.target.set_shader_input("SelectionFillAlpha", self._fill_alpha) + + def _make_default_mask(self): + image = PNMImage(1, 1, 4) + image.fill(0.0, 0.0, 0.0) + image.alpha_fill(0.0) + texture = Texture("selection_outline_default_mask") + texture.load(image) + texture.set_minfilter(SamplerState.FT_nearest) + texture.set_magfilter(SamplerState.FT_nearest) + texture.set_wrap_u(SamplerState.WM_clamp) + texture.set_wrap_v(SamplerState.WM_clamp) + return texture + diff --git a/RenderPipelineFile/rpplugins/selection_outline/shader/selection_outline.frag.glsl b/RenderPipelineFile/rpplugins/selection_outline/shader/selection_outline.frag.glsl new file mode 100644 index 00000000..856f98ba --- /dev/null +++ b/RenderPipelineFile/rpplugins/selection_outline/shader/selection_outline.frag.glsl @@ -0,0 +1,59 @@ +/** + * Selection outline post process stage. + */ + +#version 430 + +#pragma include "render_pipeline_base.inc.glsl" + +uniform sampler2D ShadedScene; +uniform sampler2D SelectionMaskTex; + +uniform float SelectionOutlineEnabled; +uniform vec4 SelectionOutlineColor; +uniform float SelectionOutlineWidth; // pixels +uniform float SelectionFillAlpha; // 0..1 + +out vec4 result; + +float sample_mask(vec2 uv) { + return textureLod(SelectionMaskTex, uv, 0).r; +} + +void main() { + vec2 uv = get_texcoord(); + vec3 scene_col = textureLod(ShadedScene, uv, 0).rgb; + + if (SelectionOutlineEnabled < 0.5) { + result = vec4(scene_col, 1.0); + return; + } + + vec2 texel = vec2(max(1.0, SelectionOutlineWidth)) / SCREEN_SIZE; + float center = sample_mask(uv); + + float max_nei = 0.0; + max_nei = max(max_nei, sample_mask(uv + vec2( texel.x, 0.0))); + max_nei = max(max_nei, sample_mask(uv + vec2(-texel.x, 0.0))); + max_nei = max(max_nei, sample_mask(uv + vec2(0.0, texel.y))); + max_nei = max(max_nei, sample_mask(uv + vec2(0.0, -texel.y))); + max_nei = max(max_nei, sample_mask(uv + vec2( texel.x, texel.y))); + max_nei = max(max_nei, sample_mask(uv + vec2( texel.x, -texel.y))); + max_nei = max(max_nei, sample_mask(uv + vec2(-texel.x, texel.y))); + max_nei = max(max_nei, sample_mask(uv + vec2(-texel.x, -texel.y))); + + // Outer contour only. + float edge = clamp(max_nei - center, 0.0, 1.0); + float fill = center * SelectionFillAlpha; + + vec3 col = scene_col; + if (fill > 0.0) { + col = mix(col, SelectionOutlineColor.rgb, fill * SelectionOutlineColor.a); + } + if (edge > 0.0) { + col = mix(col, SelectionOutlineColor.rgb, edge * SelectionOutlineColor.a); + } + + result = vec4(col, 1.0); +} + diff --git a/core/selection.py b/core/selection.py index a8565272..8661f8ca 100644 --- a/core/selection.py +++ b/core/selection.py @@ -14,6 +14,7 @@ from panda3d.core import (Vec3, Point3, Point2, LineSegs, ColorAttrib, RenderSta TransparencyAttrib, Vec4, CollisionCapsule) from direct.task.TaskManagerGlobal import taskMgr import math +from core.selection_outline import SelectionOutlineManager class SelectionSystem: """选择和变换系统类""" @@ -30,6 +31,17 @@ class SelectionSystem: self.selectedNode = None self.selectionBox = None # 选择框 self.selectionBoxTarget = None # 选择框跟踪的目标节点 + self.show_selection_box = False + self.enable_unity_outline = True + self.outline_manager = getattr(self.world, "_selection_outline_manager", None) + if not self.outline_manager: + self.outline_manager = SelectionOutlineManager( + self.world, + enabled=self.enable_unity_outline, + ) + setattr(self.world, "_selection_outline_manager", self.outline_manager) + else: + self.outline_manager.set_enabled(self.enable_unity_outline) # 坐标轴工具(Gizmo)相关 self.gizmo = None # 坐标轴 @@ -167,6 +179,21 @@ class SelectionSystem: import traceback traceback.print_exc() + def _updateSelectionOutline(self, nodePath): + """Update Unity-like selection outline visuals.""" + try: + if not getattr(self, "outline_manager", None): + return + if not self.enable_unity_outline: + self.outline_manager.clear() + return + if nodePath and not nodePath.isEmpty(): + self.outline_manager.set_targets([nodePath]) + else: + self.outline_manager.clear() + except Exception as e: + print(f"update selection outline failed: {e}") + def updateSelectionBoxGeometry(self): """更新选择框的几何形状和位置""" try: @@ -2004,8 +2031,12 @@ class SelectionSystem: # 创建选择框 #print("创建选择框...") - self.createSelectionBox(nodePath) - if self.selectionBox: + if self.show_selection_box: + self.createSelectionBox(nodePath) + else: + self.clearSelectionBox() + + if (not self.show_selection_box) or self.selectionBox: box_name = "Unknown" if self.selectionBox and not self.selectionBox.isEmpty(): box_name = self.selectionBox.getName() @@ -2015,6 +2046,7 @@ class SelectionSystem: # 创建坐标轴 #print("创建坐标轴...") + self._updateSelectionOutline(nodePath) self.createGizmo(nodePath) if self.gizmo: gizmo_name = "Unknown" @@ -2027,6 +2059,7 @@ class SelectionSystem: else: print("清除选择...") self.clearSelectionBox() + self._updateSelectionOutline(None) self.clearGizmo() print("✓ 取消选择") @@ -2115,6 +2148,9 @@ class SelectionSystem: if (self.selectionBoxTarget and self.selectionBoxTarget.isEmpty()): self.clearSelectionBox() + if self.selectedNode and self.selectedNode.isEmpty(): + self._updateSelectionOutline(None) + def setupGizmoCollision(self): if not self.gizmo or not self.gizmoXAxis or not self.gizmoYAxis or not self.gizmoZAxis: return @@ -2834,6 +2870,7 @@ class SelectionSystem: # 清理其他资源 self.clearSelectionBox() + self._updateSelectionOutline(None) self.clearGizmo() def clearSelection(self): """清除当前选择""" @@ -2841,6 +2878,7 @@ class SelectionSystem: self.selectedNode = None self.selectedObject = None self.clearSelectionBox() + self._updateSelectionOutline(None) self.clearGizmo() # 清除树形控件中的选择 diff --git a/core/selection_outline.py b/core/selection_outline.py new file mode 100644 index 00000000..5fd03135 --- /dev/null +++ b/core/selection_outline.py @@ -0,0 +1,303 @@ +from direct.task.TaskManagerGlobal import taskMgr +from panda3d.core import ( + BitMask32, + Camera, + FrameBufferProperties, + GraphicsOutput, + GraphicsPipe, + NodePath, + Shader, + Texture, + Vec4, + WindowProperties, +) + + +class SelectionOutlineManager: + """Selection mask manager feeding RenderPipeline SelectionOutlineStage.""" + + OUTLINE_PREFIX = "selectionOutline" + + def __init__( + self, + app, + enabled=True, + outline_color=Vec4(1.0, 0.55, 0.0, 1.0), + outline_width_px=2.0, + fill_alpha=0.0, + max_targets=128, + ): + self.app = app + self.enabled = bool(enabled) + self.outline_color = Vec4(outline_color) + self.outline_width_px = max(0.0, float(outline_width_px)) + self.fill_alpha = max(0.0, min(1.0, float(fill_alpha))) + self.max_targets = max(1, int(max_targets)) + + self._task_name = "selection_outline_mask_sync" + self._tracked = [] # [(source_np, clone_np), ...] + self._stage_missing_warned = False + + self._mask_root = NodePath(f"{self.OUTLINE_PREFIX}_mask_root") + self._mask_buffer = None + self._mask_texture = None + self._mask_cam = None + self._mask_cam_np = None + self._mask_shader = self._build_mask_shader() + self._buffer_size = (0, 0) + + @staticmethod + def _is_empty(np): + if not np: + return True + if hasattr(np, "isEmpty"): + return np.isEmpty() + if hasattr(np, "is_empty"): + return np.is_empty() + return False + + @classmethod + def is_outline_node(cls, node_path): + if not node_path or cls._is_empty(node_path): + return False + name = node_path.getName() if hasattr(node_path, "getName") else "" + if name.startswith(cls.OUTLINE_PREFIX): + return True + try: + if node_path.hasPythonTag("selection_outline"): + return True + except Exception: + pass + return False + + def set_enabled(self, enabled): + self.enabled = bool(enabled) + if not self.enabled: + self.clear() + self._apply_stage_inputs() + + def set_targets(self, targets): + if not self.enabled: + self.clear() + self._apply_stage_inputs() + return + + self._ensure_mask_resources() + self.clear() + + if not targets: + self._apply_stage_inputs() + return + + seen = set() + valid = [] + for target in targets: + if self._is_empty(target) or self.is_outline_node(target): + continue + key = str(target) + if key in seen: + continue + seen.add(key) + valid.append(target) + if len(valid) >= self.max_targets: + break + + for source in valid: + self._clone_target(source) + + if self._tracked: + self._start_sync_task() + self._sync_once() + print(f"[SelectionOutline] targets={len(self._tracked)} active") + else: + print("[SelectionOutline] no valid targets for outline") + + self._apply_stage_inputs() + + def clear(self): + self._stop_sync_task() + for _, clone_np in self._tracked: + if not self._is_empty(clone_np): + clone_np.removeNode() + self._tracked = [] + self._apply_stage_inputs() + + def cleanup(self): + self.clear() + self._destroy_mask_resources() + + def _build_mask_shader(self): + return Shader.make( + Shader.SL_GLSL, + """ +#version 430 +in vec4 p3d_Vertex; +uniform mat4 p3d_ModelViewProjectionMatrix; +void main() { + gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex; +} +""", + """ +#version 430 +out vec4 result; +void main() { + result = vec4(1.0, 1.0, 1.0, 1.0); +} +""", + ) + + def _clone_target(self, source): + try: + clone_np = source.copyTo(self._mask_root) + if self._is_empty(clone_np): + return + if not self._node_has_geom(clone_np): + clone_np.removeNode() + return + clone_np.setName(f"{self.OUTLINE_PREFIX}_{source.getName()}") + clone_np.setPythonTag("selection_outline", True) + clone_np.setCollideMask(BitMask32.allOff()) + clone_np.setMat(self.app.render, source.getMat(self.app.render)) + self._tracked.append((source, clone_np)) + except Exception as exc: + print(f"[SelectionOutline] clone failed: {exc}") + + def _node_has_geom(self, np): + if self._is_empty(np): + return False + try: + node = np.node() + if node and hasattr(node, "isGeomNode") and node.isGeomNode(): + return True + except Exception: + pass + try: + return not np.find("**/+GeomNode").isEmpty() + except Exception: + return False + + def _start_sync_task(self): + taskMgr.remove(self._task_name) + taskMgr.add(self._sync_task, self._task_name) + + def _stop_sync_task(self): + taskMgr.remove(self._task_name) + + def _sync_task(self, task): + self._sync_once() + return task.cont + + def _sync_once(self): + if not self.enabled: + self._apply_stage_inputs() + return + + self._ensure_mask_resources() + alive = [] + for source, clone_np in self._tracked: + if self._is_empty(source) or self._is_empty(clone_np): + if not self._is_empty(clone_np): + clone_np.removeNode() + continue + clone_np.setMat(self.app.render, source.getMat(self.app.render)) + alive.append((source, clone_np)) + self._tracked = alive + self._apply_stage_inputs() + + def _get_stage(self): + rp = getattr(self.app, "render_pipeline", None) + if not rp or not getattr(rp, "stage_mgr", None): + return None + return rp.stage_mgr.get_stage("SelectionOutlineStage") + + def _apply_stage_inputs(self): + stage = self._get_stage() + if not stage: + if not self._stage_missing_warned: + print("[SelectionOutline] SelectionOutlineStage not found; plugin may be disabled.") + self._stage_missing_warned = True + return + + self._stage_missing_warned = False + stage.set_outline_style( + color=self.outline_color, + width_px=self.outline_width_px, + fill_alpha=self.fill_alpha, + ) + stage.set_mask_texture(self._mask_texture) + stage.set_enabled_outline(self.enabled and bool(self._tracked)) + + def _get_window_size(self): + if not getattr(self.app, "win", None): + return 1, 1 + return max(1, self.app.win.getXSize()), max(1, self.app.win.getYSize()) + + def _ensure_mask_resources(self): + size = self._get_window_size() + if size != self._buffer_size: + self._destroy_mask_resources() + self._buffer_size = size + if self._mask_buffer: + return + + if not getattr(self.app, "graphicsEngine", None) or not getattr(self.app, "win", None): + return + + w, h = self._buffer_size + win_props = WindowProperties() + win_props.setSize(w, h) + fb_props = FrameBufferProperties() + fb_props.setRgbaBits(8, 8, 8, 8) + fb_props.setDepthBits(24) + + self._mask_buffer = self.app.graphicsEngine.make_output( + self.app.pipe, + "selection_outline_mask", + -80, + fb_props, + win_props, + GraphicsPipe.BFRefuseWindow, + self.app.win.getGsg(), + self.app.win, + ) + if not self._mask_buffer: + print("[SelectionOutline] failed to create mask buffer") + return + + self._mask_texture = Texture("selection_outline_mask_tex") + self._mask_texture.setMinfilter(Texture.FTNearest) + self._mask_texture.setMagfilter(Texture.FTNearest) + self._mask_buffer.addRenderTexture(self._mask_texture, GraphicsOutput.RTMBindOrCopy) + self._mask_buffer.setClearColor(Vec4(0, 0, 0, 0)) + self._mask_buffer.setClearColorActive(True) + self._mask_buffer.setActive(True) + + self._mask_cam = Camera("selection_outline_mask_camera") + self._mask_cam.setScene(self._mask_root) + self._mask_cam.setLens(self.app.camLens) + self._mask_cam_np = self.app.cam.attachNewNode(self._mask_cam) + + dr = self._mask_buffer.makeDisplayRegion() + dr.setCamera(self._mask_cam_np) + + state_np = NodePath("selection_outline_mask_state") + state_np.setShader(self._mask_shader, 10000) + state_np.setLightOff(1) + state_np.setMaterialOff(1) + state_np.setTextureOff(1) + state_np.setColorOff(1) + self._mask_cam.setInitialState(state_np.getState()) + + def _destroy_mask_resources(self): + if self._mask_cam_np and not self._is_empty(self._mask_cam_np): + self._mask_cam_np.removeNode() + self._mask_cam_np = None + self._mask_cam = None + + if self._mask_buffer and getattr(self.app, "graphicsEngine", None): + try: + self.app.graphicsEngine.removeWindow(self._mask_buffer) + except Exception: + pass + self._mask_buffer = None + self._mask_texture = None diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index 5a671f1a..4835ea6c 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -49,6 +49,7 @@ from imgui_bundle import imgui from rpcore.effect import Effect from .ssbo_controller import ObjectController +from core.selection_outline import SelectionOutlineManager class SSBOEditor: """ @@ -88,6 +89,10 @@ class SSBOEditor: 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'): @@ -521,12 +526,40 @@ class SSBOEditor: if chunk_id is not None and chunk_id in self.controller.chunks: self.controller.chunks[chunk_id]["dirty"] = True + 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 clear_selection(self): 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: @@ -585,6 +618,8 @@ class SSBOEditor: # 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: @@ -594,6 +629,7 @@ class SSBOEditor: 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: