MetaCoreEngineV2/tools/camera_orbit_controller.py
2026-01-13 17:06:06 +08:00

448 lines
18 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.

# -*- 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 的 offscreenmouseWatcher 常为空,改用事件驱动
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")