448 lines
18 KiB
Python
448 lines
18 KiB
Python
# -*- 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")
|