# -*- coding: utf-8 -*- """ CameraOrbitController --------------------- 通用的轨道/平移/缩放 + WASD/QE 漫游控制器,操作习惯贴近 Unity: - Alt + 左键拖动:围绕目标轨道旋转。 - 中键(滚轮按下)拖动:平移。 - Alt + 右键拖动或滚轮滚动:缩放/推拉。 - 右键按住:自由视角旋转;右键按住时 WASD/QE/Space 飞行移动。 用法: from camera_orbit_controller import CameraOrbitController controller = CameraOrbitController(base) # base 为 ShowBase 实例 """ import importlib import importlib.util HAS_IMGUI = False if importlib.util.find_spec("imgui_bundle"): from imgui_bundle import imgui HAS_IMGUI = True import math from typing import Optional from panda3d.core import LPoint3, Vec3, KeyboardButton, NodePath from direct.showbase.ShowBaseGlobal import globalClock from direct.showbase.ShowBase import ShowBase from panda3d.core import MouseButton,MouseWatcher import panda3d.core as p3d from direct.task import Task class CameraOrbitController: def __init__( self, base:ShowBase, target: Optional[LPoint3] = None, target_model: Optional[NodePath] = None, distance: float = 12.0, yaw: float = 30.0, pitch: float = -20.0, move_speed: float = 12.0, orbit_sensitivity: float = 0.3, orbit_requires_alt: bool = True, pan_factor: float = 0.002, zoom_factor: float = 0.001, block_io = None ): # 传入的 ShowBase 实例(含渲染节点、相机、窗口、任务管理器) self.base = base # 关闭 Panda3D 默认 TrackballController,避免它同时改动相机姿态导致绕轨时角度怪异 if hasattr(self.base, "disableMouse"): self.base.disableMouse() self.render: Optional[ShowBase] = base.render self.cam: Optional[NodePath] = getattr(base, "camera", base.cam) self.win = base.win self.mouseWatcher:MouseWatcher = getattr(base, "mouseWatcherNode", None) self.block_io = block_io # 相机围绕的目标点与初始距离/角度 if target_model and (target_model.children or not target_model.isEmpty): bounds = target_model.getTightBounds() min_pt, max_pt = bounds self.cam_target = (min_pt + max_pt) * 0.5 extent = (max_pt - min_pt) self.distance = extent.length() * distance else: self.cam_target = target or LPoint3(0, 0, 0) self.distance = distance self.focus_target_node: Optional[NodePath] = target_model self.yaw = yaw self.pitch = pitch # 运动与灵敏度参数 self.move_speed = move_speed self.orbit_sensitivity = orbit_sensitivity self.orbit_requires_alt = orbit_requires_alt self.pan_factor = pan_factor self.zoom_factor = zoom_factor # 鼠标状态缓存 self.lmb_down = False # 左键 self.mmb_down = False # 中键(滚轮) self.rmb_down = False # 右键 self.mods = [] # 修饰键(来自 Qt 事件的 alt/shift/control) self.base.accept("alt-lalt", lambda: self._set_mod("alt", True)) self.base.accept("lalt-up", lambda: self._set_mod("alt", False)) # self.base.accept("alt-mouse1", self._alt_mouse1) # self.base.accept("alt-mouse1-up", self._alt_mouse1_up) self._last_pointer = None # 键盘移动状态 self.move_state = {"w": False, "a": False, "s": False, "d": False, "q": False, "e": False, "space": False} # 事件注册与任务添加 self.using_mousewatcher = self.mouseWatcher is not None self.pointer_xy = None # 记录 Qt 事件传入的鼠标位置 self._update_camera() self._mouse_task = None self._focus_task = None if self.using_mousewatcher: self._mouse_task = base.taskMgr.add(self._poll_mouse, "camera-orbit-mouse") # print("Using mouseWatcher") else: # print("Not Using mouseWatcher") pass self._register_events() self._move_task = base.taskMgr.add(self._update_task, "camera-orbit-update") def _alt_mouse1(self, evt): self._set_mod("alt", True) self._set_button_state("lmb", True, evt) def _alt_mouse1_up(self, evt): self._set_mod("alt", False) self._set_button_state("lmb", False, evt) def _set_mod(self, mod: str, state: bool): if state and mod not in self.mods: self.mods.append(mod) elif not state and mod in self.mods: self.mods.remove(mod) # 公共接口 ----------------------------------------------------------- def set_target(self, target: NodePath|None): """设置新的注视点并立即更新相机。""" if target: self.cam_target = target.get_pos(self.render) self.focus_target_node = target def set_focus_target(self, focus_node: Optional[NodePath]): """设置按 F 聚焦时使用的目标 NodePath。""" self.focus_target_node = focus_node def destroy(self): """清理事件与任务,便于在销毁/切换场景时释放。""" if self._mouse_task: self.base.taskMgr.remove(self._mouse_task) if self._move_task: self.base.taskMgr.remove(self._move_task) if self._focus_task: self.base.taskMgr.remove(self._focus_task) for key in list(self.move_state.keys()): self.base.ignore(key) self.base.ignore(f"{key}-up") for ev in [ "wheel_up", "wheel_down", "wheel", "mouse1", "mouse1-up", "mouse2", "mouse2-up", "mouse3", "mouse3-up", "mouse-move", ]: self.base.ignore(ev) self.base.ignore("f") # 内部逻辑 ----------------------------------------------------------- def _register_events(self): """注册鼠标与键盘事件。""" # 滚轮(Qt 侧会发 'wheel' 事件,常规 Panda3D 为 wheel_up/down) self.base.accept("wheel_up", lambda: self._on_wheel(120)) self.base.accept("wheel_down", lambda: self._on_wheel(-120)) self.base.accept("wheel", lambda evt: self._on_wheel(evt.get("delta", 0) if isinstance(evt, dict) else 0)) for key in self.move_state: self.base.accept(key, self._set_key, [key, True]) self.base.accept(f"{key}-up", self._set_key, [key, False]) # 对嵌入 Qt 的 offscreen,mouseWatcher 常为空,改用事件驱动 self.base.accept("mouse1", lambda evt=None: self._set_button_state("lmb", True, evt)) self.base.accept("mouse1-up", lambda evt=None: self._set_button_state("lmb", False, evt)) self.base.accept("mouse2", lambda evt=None: self._set_button_state("rmb", True, evt)) self.base.accept("mouse2-up", lambda evt=None: self._set_button_state("rmb", False, evt)) self.base.accept("mouse3", lambda evt=None: self._set_button_state("mmb", True, evt)) self.base.accept("mouse3-up", lambda evt=None: self._set_button_state("mmb", False, evt)) self.base.accept("mouse-move", self._on_mouse_move_event) # Unity 风格:按 F 聚焦当前选中物体 self.base.accept("f", self.focus_on_node) def _on_wheel(self, delta: float): """滚轮缩放,按距离比例调整。""" zoom = 1.0 - (delta * self.zoom_factor) zoom = max(0.2, min(5.0, zoom)) self.distance = max(0.5, self.distance * zoom) self._update_camera() def _set_key(self, key, pressed): # print(f"{key} {pressed}") """更新键盘按下/抬起状态。""" self.move_state[key] = pressed def _set_button_state(self, name: str, down: bool, evt=None): """Qt 嵌入时由 messenger 事件更新按键状态。""" if name == "lmb": self.lmb_down = down elif name == "mmb": self.mmb_down = down elif name == "rmb": self.rmb_down = down if isinstance(evt, dict) and "x" in evt and "y" in evt: self.pointer_xy = (evt["x"], evt["y"]) self._last_pointer = None def _on_mouse_move_event(self, evt: dict): """Qt 嵌入时的鼠标移动事件处理(evt: {'x':..,'y':..})。""" if not isinstance(evt, dict): return x, y = evt.get("x"), evt.get("y") if x is None or y is None: return if self._last_pointer is None: self._last_pointer = (x, y) return dx = x - self._last_pointer[0] dy = y - self._last_pointer[1] self._last_pointer = (x, y) alt_down = ("alt" in self.mods) if self.lmb_down and (alt_down or not self.orbit_requires_alt): self.yaw += dx * self.orbit_sensitivity self.pitch = max(-89.0, min(89.0, self.pitch - dy * self.orbit_sensitivity)) self._update_camera() elif self.mmb_down: quat = self.cam.getQuat(self.render) right = quat.getRight() up = quat.getUp() pan_speed = self.distance * self.pan_factor self.cam_target += right * (-dx * pan_speed) self.cam_target += up * (dy * pan_speed) self._update_camera() elif self.rmb_down: self.yaw += dx * self.orbit_sensitivity self.pitch = max(-89.0, min(89.0, self.pitch - dy * self.orbit_sensitivity)) self._update_camera() def _poll_mouse(self, task): """轮询鼠标指针,计算 dx/dy 并执行轨道或平移。""" if self.mouseWatcher and self.mouseWatcher.hasMouse(): # 使用即时按钮状态,避免按 Alt 后再按键导致的事件缺失 current_lmb = self.mouseWatcher.isButtonDown(MouseButton.one()) current_mmb = self.mouseWatcher.isButtonDown(MouseButton.two()) current_rmb = self.mouseWatcher.isButtonDown(MouseButton.three()) if (current_lmb != self.lmb_down or current_mmb != self.mmb_down or current_rmb != self.rmb_down): self._last_pointer = None self.lmb_down = current_lmb self.mmb_down = current_mmb self.rmb_down = current_rmb x = self.win.getPointer(0).getX() y = self.win.getPointer(0).getY() if self._last_pointer is None: self._last_pointer = (x, y) return task.cont dx = x - self._last_pointer[0] dy = y - self._last_pointer[1] self._last_pointer = (x, y) # Alt 检测兼容左右 Alt(部分系统/窗口管理器会屏蔽 Alt+LMB,可将 orbit_requires_alt 设为 False 仅用 LMB 轨道) alt_down = ( self.mouseWatcher.isButtonDown(KeyboardButton.alt()) or self.mouseWatcher.isButtonDown(KeyboardButton.lalt()) or self.mouseWatcher.isButtonDown(KeyboardButton.ralt()) ) # Alt + 左键:轨道旋转 # print(f"alt_down->{alt_down} lmb_down->{self.lmb_down} lmb_down->{self.lmb_down}") if self.lmb_down and (alt_down or not self.orbit_requires_alt): self.yaw += dx * self.orbit_sensitivity self.pitch = max(-89.0, min(89.0, self.pitch -dy * self.orbit_sensitivity)) self._update_camera() # 中键:平移 elif self.mmb_down: quat = self.cam.getQuat(self.render) right = quat.getRight() up = quat.getUp() pan_speed = self.distance * self.pan_factor self.cam_target += right * (-dx * pan_speed) self.cam_target += up * (dy * pan_speed) self._update_camera() # Alt + 右键:推拉(缩放) elif alt_down and self.rmb_down: dolly = 1.0 - (dy * self.zoom_factor) dolly = max(0.2, min(5.0, dolly)) self.distance = max(0.5, self.distance * dolly) self._update_camera() # 右键:自由视角旋转 elif self.rmb_down: self.yaw += dx * self.orbit_sensitivity self.pitch = max(-89.0, min(89.0, self.pitch -dy * self.orbit_sensitivity)) self._update_camera() return task.cont def _update_task(self, task:Task): if HAS_IMGUI: io = imgui.get_io() if io.want_capture_mouse: return task.cont """每帧根据键盘状态推动摄像机目标点。""" dt = globalClock.getDt() move_vec = Vec3(0, 0, 0) quat = self.cam.getQuat(self.render) forward = quat.getForward() right = quat.getRight() up = Vec3(0, 0, 1) # 只有右键按住时启用 WASD/QE(符合 Unity 右键飞行视角) if self.rmb_down: if self.move_state["w"]: move_vec += forward if self.move_state["s"]: move_vec -= forward if self.move_state["a"]: move_vec -= right if self.move_state["d"]: move_vec += right if self.move_state["q"]: move_vec -= up if self.move_state["e"] or self.move_state["space"]: move_vec += up if move_vec.length_squared() > 0: move_vec.normalize() move_vec *= self.move_speed * dt self.cam_target += move_vec self._update_camera() return task.cont def _update_camera(self, look_at: bool = True): if HAS_IMGUI: io = imgui.get_io() if io.want_capture_mouse: return """根据 yaw/pitch/距离刷新相机位置并朝向目标。""" rad_yaw = math.radians(self.yaw) rad_pitch = math.radians(self.pitch) dir_vec = Vec3( math.sin(rad_yaw) * math.cos(rad_pitch), math.cos(rad_yaw) * math.cos(rad_pitch), math.sin(rad_pitch), ) cam_pos = self.cam_target - dir_vec * self.distance self.cam.setPos(cam_pos) if (look_at): self.cam.lookAt(self.cam_target) # 聚焦 --------------------------------------------------------------- def focus_on_node(self, node: Optional[NodePath] = None, duration: float = 0.2, padding: float = 1.2): """ 按 F 聚焦到指定物体或最近一次设置的 focus_target_node。 会根据包围盒调整距离,空物体则只平移到其位置。 """ target_node = node or self.focus_target_node if target_node is None: return if hasattr(target_node, "is_empty") and target_node.is_empty(): return center, target_distance, has_bounds = self._compute_focus_goal(target_node, padding) if not has_bounds: # 空物体或无包围盒:仅移动到位置,距离保持当前的一个合理值 target_distance = max(self.distance, 3.0) if abs(p3d.LPoint3f(center-self.cam.get_pos()).length() - target_distance) < 0.1: target_distance = max(target_distance / 3.0 , 1.0) self._start_focus_lerp(center, target_distance, duration) def _compute_focus_goal(self, node: NodePath, padding: float) -> tuple[LPoint3, float, bool]: """返回聚焦中心、距离以及是否使用了有效包围盒。""" try: bounds = node.get_tight_bounds(self.render) if hasattr(node, "get_tight_bounds") else node.getTightBounds(self.render) except Exception: bounds = None if bounds and len(bounds) >= 2 and bounds[0] is not None and bounds[1] is not None: bmin, bmax = bounds extent = bmax - bmin if extent.length_squared() > 1e-6: center = (bmin + bmax) * 0.5 radius = extent.length() * 0.5 * padding lens = self.base.cam.node().getLens() if self.cam else None if lens: fov = lens.getFov() half_fov_x = max(1e-3, math.radians(float(fov[0])) * 0.5) half_fov_y = max(1e-3, math.radians(float(fov[1])) * 0.5) dist_x = radius / math.tan(half_fov_x) dist_y = radius / math.tan(half_fov_y) distance = max(dist_x, dist_y) near = getattr(lens, "getNear", lambda: 0.0)() distance = max(distance, near + radius * 0.5) else: distance = radius / math.tan(math.radians(30)) return center, max(distance, 0.5), True # 无有效包围盒:使用节点位置作为聚焦中心 try: center = node.get_pos(self.render) except Exception: center = self.cam_target return center, max(self.distance, 0.5), False def _start_focus_lerp(self, target_center: LPoint3, target_distance: float, duration: float): """平滑插值到新的目标与距离。""" if self._focus_task: self.base.taskMgr.remove(self._focus_task) self._focus_task = None start_target = LPoint3(self.cam_target) start_distance = float(self.distance) duration = max(0.01, duration) def _lerp(task): t = min(task.time / duration, 1.0) # smoothstep t = t * t * (3 - 2 * t) self.cam_target = start_target * (1.0 - t) + target_center * t self.distance = start_distance + (target_distance - start_distance) * t self._update_camera() if t >= 1.0: self._focus_task = None return task.done return task.cont self._focus_task = self.base.taskMgr.add(_lerp, "camera-focus-lerp")