Merge remote-tracking branch 'origin/geng' into IMgui_hu

This commit is contained in:
Hector 2026-02-28 11:06:14 +08:00
commit 5752559cdd
10 changed files with 528 additions and 2 deletions

View File

@ -13,6 +13,7 @@ enabled:
- scattering
- skin_shading
- sky_ao
- selection_outline
- smaa
- ssr
# - clouds

View File

@ -63,5 +63,6 @@ global_stage_order:
# Finishing stages, do not insert anything below
- UpscaleStage
- SelectionOutlineStage
- FinalStage
- UpdatePreviousPipesStage

View File

@ -0,0 +1,2 @@
"""Selection outline plugin package."""

View File

@ -0,0 +1,3 @@
settings:
daytime_settings:

View File

@ -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)

View File

@ -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

View File

@ -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);
}

View File

@ -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 # 坐标轴
@ -207,6 +219,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:
@ -2065,8 +2092,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()
@ -2076,6 +2107,7 @@ class SelectionSystem:
# 创建坐标轴
#print("创建坐标轴...")
self._updateSelectionOutline(nodePath)
self.createGizmo(nodePath)
if self.gizmo:
gizmo_name = "Unknown"
@ -2088,6 +2120,7 @@ class SelectionSystem:
else:
print("清除选择...")
self.clearSelectionBox()
self._updateSelectionOutline(None)
self.clearGizmo()
print("✓ 取消选择")
@ -2176,6 +2209,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
@ -2895,6 +2931,7 @@ class SelectionSystem:
# 清理其他资源
self.clearSelectionBox()
self._updateSelectionOutline(None)
self.clearGizmo()
def clearSelection(self):
"""清除当前选择"""
@ -2902,6 +2939,7 @@ class SelectionSystem:
self.selectedNode = None
self.selectedObject = None
self.clearSelectionBox()
self._updateSelectionOutline(None)
self.clearGizmo()
# 清除树形控件中的选择

303
core/selection_outline.py Normal file
View File

@ -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

View File

@ -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:
"""
@ -91,6 +92,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'):
@ -573,12 +578,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:
@ -637,6 +670,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:
@ -646,6 +681,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: