From 9b72f10b43c2cc3117dbdf14ce9de7617bbbd6bc Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Fri, 18 Sep 2026 03:06:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96Gizmo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/canvas/DirectorCanvas.grid.test.ts | 11 +- src/editor/canvas/DirectorCanvas.tsx | 110 ++++++++--- src/editor/canvas/SceneRoot.rotation.test.ts | 16 +- src/editor/canvas/SceneRoot.tsx | 132 +++++++------ src/editor/canvas/ViewportToolbar.test.tsx | 5 +- src/editor/canvas/sceneTransform.ts | 16 ++ src/editor/io/videoExport.test.ts | 5 +- src/editor/io/videoExport.ts | 10 +- src/editor/panels/CameraPanel.test.tsx | 63 ++++-- src/editor/panels/CameraPanel.tsx | 187 ++++++++++++------ src/editor/panels/TimelinePanel.test.tsx | 46 ++++- src/editor/panels/TimelinePanel.tsx | 9 +- src/editor/runtime/keyframeCommand.ts | 2 +- src/editor/runtime/playbackTransforms.test.ts | 11 +- src/editor/runtime/playbackTransforms.ts | 11 +- src/editor/schema/cameraGeometry.test.ts | 33 +++- src/editor/schema/cameraGeometry.ts | 185 ++++++++++++++++- src/editor/schema/directorProject.ts | 43 +++- src/editor/schema/timeline.test.ts | 15 +- src/editor/schema/timeline.ts | 7 +- src/editor/store/directorStore.test.ts | 35 ++-- src/editor/store/directorStore.ts | 87 ++++++-- src/styles/index.css | 2 + 23 files changed, 787 insertions(+), 254 deletions(-) diff --git a/src/editor/canvas/DirectorCanvas.grid.test.ts b/src/editor/canvas/DirectorCanvas.grid.test.ts index 8eb8f24..3c16313 100644 --- a/src/editor/canvas/DirectorCanvas.grid.test.ts +++ b/src/editor/canvas/DirectorCanvas.grid.test.ts @@ -7,7 +7,7 @@ import { shouldRenderViewportGrid, } from "./DirectorCanvas"; import { createDefaultDirectorProject } from "../store/directorStore"; -import { getCameraViewSnapshotFromShot } from "../schema/cameraGeometry"; +import { getCameraLookRotationFromTarget, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry"; import { PerspectiveCamera, Vector3 } from "three"; function expectTupleToBeCloseTo( @@ -85,7 +85,14 @@ it("starts the director and default camera view from a centered front compositio expect(defaultCameraView.fov).toBe(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.fov); expectTupleToBeCloseTo(defaultCameraView.position, defaultCamera.transform.position); - expect(defaultCameraView.target).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target); + // 机位存的是注视朝向角:默认机位 = 默认导演视角那条视线的角度 + expectTupleToBeCloseTo( + defaultCameraView.lookRotation, + getCameraLookRotationFromTarget( + DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position, + DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target + ) + ); expect(defaultCamera.transform.position[0]).toBe(0); expect(defaultCamera.transform.position[1]).toBeGreaterThan(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target[1]); }); diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index 22263e1..96c33dc 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -17,7 +17,15 @@ import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/ca import { setVideoExportContext } from "../io/videoExportBridge"; import { buildScreenshotMeta, type ScreenshotResult } from "../io/screenshotExport"; import { useDirectorStore, type CameraShotSnapshot } from "../store/directorStore"; -import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry"; +import { + DEFAULT_DIRECTOR_CAMERA_LOOK_POINT, + DEFAULT_DIRECTOR_CAMERA_POSITION, + getCameraLookPoint, + getCameraRenderQuaternion, + getCameraViewSnapshotFromShot, + getWorldCameraViewSnapshot, + type CameraViewSnapshot, +} from "../schema/cameraGeometry"; import type { DirectorObject, DirectorTransform, SceneSettings } from "../schema/directorProject"; import { degreesRotationToRadians } from "../schema/directorProject"; import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes"; @@ -35,7 +43,15 @@ import { ViewportBackground } from "./ViewportBackground"; import { ViewportToolbar } from "./ViewportToolbar"; import { getViewportAspectFrameRect, type ViewportSafeAreaInsets } from "./viewportAspectFrame"; -export const DEFAULT_DIRECTOR_VIEW_SNAPSHOT: CameraShotSnapshot = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT; +/** + * 导演视角的初始位姿:OrbitControls 需要一个轨道中心点,所以这里存的是「注视点」; + * 它属于视口状态,不是工程数据 —— 工程里的机位统一用注视朝向角(lookRotation)表示。 + */ +export const DEFAULT_DIRECTOR_VIEW_SNAPSHOT: CameraShotSnapshot = { + fov: 50, + position: [...DEFAULT_DIRECTOR_CAMERA_POSITION], + target: [...DEFAULT_DIRECTOR_CAMERA_LOOK_POINT], +}; const VIEWPORT_FRAME_PADDING = 40; const VIEWPORT_TOOLBAR_BOTTOM_OFFSET = 40; const DEFAULT_VIEWPORT_TOOLBAR_HEIGHT = 44; @@ -168,6 +184,19 @@ function applySnapshotToCamera(camera: ThreePerspectiveCamera, snapshot: CameraS camera.updateMatrixWorld(); } +/** + * 把「机位视角」写到渲染相机上:位置 + 注视朝向角直接合成姿态。 + * 与导演视角的 applySnapshotToCamera 分开 —— 那边是 OrbitControls 的轨道中心点(世界坐标), + * 这边是工程里的注视朝向角,两者不是一回事。 + */ +function applyCameraViewToCamera(camera: ThreePerspectiveCamera, view: CameraViewSnapshot) { + camera.fov = view.fov; + camera.position.set(...view.position); + camera.quaternion.copy(getCameraRenderQuaternion(view.lookRotation)); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(); +} + /** * 外部改视角(重置视角 / 坐标控件切轴)时「当场」结算残余惯性并把视角写回相机与 OrbitControls。 * 返回是否真的写回去了 —— 机位视角下没有 OrbitControls,只能等切回导演视角时由副作用补写。 @@ -472,7 +501,8 @@ function CanvasCaptureBridge({ | { id: string; fov: number; - target: [number, number, number]; + position: [number, number, number]; + lookRotation: [number, number, number]; } | undefined; bottomPadding: number; @@ -497,7 +527,8 @@ function CanvasCaptureBridge({ }): Promise => { const target = new Vector3(0, 1.2, 0); if (viewMode === "camera" && activeCamera) { - target.fromArray(activeCamera.target); + // 机位只存注视朝向角,环绕用的中心点按视线前方现算一个 + target.fromArray(getCameraLookPoint(activeCamera.position, activeCamera.lookRotation)); } else if (controlsRef.current?.target) { target.copy(controlsRef.current.target); } @@ -530,24 +561,20 @@ function CanvasCaptureBridge({ // 机位面板触发的「当前机位」截图:渲染的是该机位自己的视角, // 而不是编辑器此刻所在的导演视角(导演视角通常更高、更远), // 否则用户在机位面板生成的预览会和切到相机视角看到的不一致。 - if (source === "camera-panel" && cameraId) { - const captureCamera = useDirectorStore - .getState() - .project.cameras.find((item) => item.id === cameraId); - if (captureCamera) { - const view = getCameraViewSnapshotFromShot(captureCamera); - const savedPosition = workingCamera.position.clone(); - const savedQuaternion = workingCamera.quaternion.clone(); - const savedFov = workingCamera.fov; - applySnapshotToCamera(workingCamera, view); - try { - return [snapshot("当前机位", [...view.target])]; - } finally { - workingCamera.position.copy(savedPosition); - workingCamera.quaternion.copy(savedQuaternion); - workingCamera.fov = savedFov; - workingCamera.updateProjectionMatrix(); - } + if (source === "camera-panel" && cameraId && activeCamera) { + // activeCamera 已经是世界坐标的机位视角(与切到机位视角一致): + // 直接用它渲染,截图预览才和用户在机位视角里看到的画面吻合。 + const savedPosition = workingCamera.position.clone(); + const savedQuaternion = workingCamera.quaternion.clone(); + const savedFov = workingCamera.fov; + applyCameraViewToCamera(workingCamera, activeCamera); + try { + return [snapshot("当前机位", getCameraLookPoint(activeCamera.position, activeCamera.lookRotation))]; + } finally { + workingCamera.position.copy(savedPosition); + workingCamera.quaternion.copy(savedQuaternion); + workingCamera.fov = savedFov; + workingCamera.updateProjectionMatrix(); } } return [snapshot(source === "camera-panel" ? "当前机位" : "当前视角")]; @@ -765,14 +792,29 @@ export function DirectorCanvas() { const panoramaAsset = assets.find((item) => item.id === panoramaAssetId); const showViewportGrid = shouldRenderViewportGrid(hasPanorama, sceneSettings.snapToGrid); const activeCameraView = activeCamera ? getCameraViewSnapshotFromShot(activeCamera) : undefined; + // 机位线框 / 三轴 gizmo 都画在场景组里(场景变换之内),而「机位视角」渲染相机、 + // 面板数值、截图中心点都该落在世界坐标,所以这里把局部机位换算成世界机位。 + const activeCameraWorldView = activeCameraView + ? getWorldCameraViewSnapshot(activeCameraView, sceneSettings) + : undefined; + /** 截图桥需要的机位视角:在世界机位视角上补一个 id,用来把截图归到对应机位 */ + const activeCameraCaptureView = + activeCamera && activeCameraWorldView ? { id: activeCamera.id, ...activeCameraWorldView } : undefined; const viewportAspectRatio = useDirectorStore((state) => state.viewportAspectRatio); const viewportRuleOfThirdsEnabled = useDirectorStore((state) => state.viewportRuleOfThirdsEnabled); const viewportPanelsCollapsed = useDirectorStore((state) => state.viewportPanelsCollapsed); const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId); const setViewMode = useDirectorStore((state) => state.setViewMode); const setViewportRuleOfThirdsEnabled = useDirectorStore((state) => state.setViewportRuleOfThirdsEnabled); - const visibleViewportSnapshot = - viewMode === "camera" && activeCameraView ? activeCameraView : directorViewSnapshot; + // 坐标控件 / 方位切换只认「位置 + 一个中心点」,机位视角下按视线前方现算一个注视点 + const visibleViewportSnapshot: CameraShotSnapshot = + viewMode === "camera" && activeCameraWorldView + ? { + fov: activeCameraWorldView.fov, + position: activeCameraWorldView.position, + target: getCameraLookPoint(activeCameraWorldView.position, activeCameraWorldView.lookRotation), + } + : directorViewSnapshot; const viewportSafeAreaInsets: ViewportSafeAreaInsets = viewportPanelsCollapsed ? { left: 0, right: 0, top: 0, bottom: 0 } : { @@ -872,6 +914,9 @@ export function DirectorCanvas() {
event.preventDefault()} > - {viewMode === "camera" && activeCameraView ? ( + {viewMode === "camera" && activeCameraWorldView ? ( camera.lookAt(...activeCameraView.target)} + position={activeCameraWorldView.position} + quaternion={ + getCameraRenderQuaternion(activeCameraWorldView.lookRotation).toArray() as [ + number, + number, + number, + number, + ] + } /> ) : null} function getFrustumMetrics({ fov = 50, - target = [0, 1.2, 0], + lookRotation = [0, 0, 0], }: { fov?: number; - target?: [number, number, number]; + lookRotation?: [number, number, number]; }) { const lines = getViewportCameraFrustumLines({ id: "cam_1", @@ -59,7 +59,7 @@ function getFrustumMetrics({ scale: [1, 1, 1], }, targetMode: "manual", - target, + lookRotation, lastCaptureUrl: null, }); const points = lines.flat(); @@ -86,10 +86,10 @@ it("uses a 16:9 front viewfinder sized by the camera fov", () => { expect(metrics.maxHalfWidth / metrics.maxHalfHeight).toBeCloseTo(16 / 9); }); -it("keeps the viewport camera viewfinder length fixed when the shot target distance changes", () => { - const nearMetrics = getFrustumMetrics({ target: [0, 1.2, 5] }); - const defaultMetrics = getFrustumMetrics({ target: [0, 1.2, 0] }); - const farMetrics = getFrustumMetrics({ target: [0, 1.2, -9] }); +it("keeps the viewport camera viewfinder length fixed when the shot look angles change", () => { + const nearMetrics = getFrustumMetrics({ lookRotation: [20, 0, 0] }); + const defaultMetrics = getFrustumMetrics({ lookRotation: [0, 0, 0] }); + const farMetrics = getFrustumMetrics({ lookRotation: [-35, 90, 0] }); expect(defaultMetrics.maxDepth).toBeCloseTo(nearMetrics.maxDepth); expect(farMetrics.maxDepth).toBeCloseTo(defaultMetrics.maxDepth); @@ -210,7 +210,7 @@ it("starts the viewfinder lines from the centered front lens frame", () => { scale: [1, 1, 1], }, targetMode: "manual", - target: [0, 1.2, 0], + lookRotation: [0, 0, 0], lastCaptureUrl: null, }); diff --git a/src/editor/canvas/SceneRoot.tsx b/src/editor/canvas/SceneRoot.tsx index c17e2d5..82e203b 100644 --- a/src/editor/canvas/SceneRoot.tsx +++ b/src/editor/canvas/SceneRoot.tsx @@ -1,10 +1,11 @@ import { Html, Line, TransformControls, type TransformControlsProps } from "@react-three/drei"; -import { useLoader, type ThreeEvent } from "@react-three/fiber"; +import { createPortal, useLoader, useThree, type ThreeEvent } from "@react-three/fiber"; import { Suspense, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { Box3, CatmullRomCurve3, DoubleSide, + Euler, Matrix4, Quaternion, Vector3, @@ -23,11 +24,14 @@ import type { import { degreesRotationToRadians, radiansRotationToDegrees, + radiansToDegrees, } from "../schema/directorProject"; import { + CAMERA_LOOK_EULER_ORDER, VIEWPORT_CAMERA_ASPECT, VIEWPORT_CAMERA_FRUSTUM_DEPTH, VIEWPORT_CAMERA_VISUAL_SCALE, + getCameraLookQuaternion, } from "../schema/cameraGeometry"; import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels"; import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTimes } from "../schema/trajectoryMotion"; @@ -45,6 +49,9 @@ import { HIDE_FROM_VIEWPORT_CAPTURE_KEY } from "./viewportCapture"; import { getCrowdAnchorTransform } from "../store/directorStore"; export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath"; +// 旧名字:线框姿态以前是「位置 + 注视点」算出来的,现在统一由「位置 + 注视朝向角」算, +// 这里保留转发,外部(含单测)仍能按注视点取姿态。 +export { getCameraQuaternionFromTarget as getViewportCameraQuaternion } from "../schema/cameraGeometry"; const VIEWPORT_CAMERA_LINE = "#A9D8FF"; /** 轨迹:路点标记 / 连线的兜底色(默认沿用角色自身颜色),选中点用高亮色 */ @@ -54,8 +61,6 @@ const TRAJECTORY_SELECTED_COLOR = "#FFC857"; const TRAJECTORY_PLANE_SIZE = 600; const VIEWPORT_CAMERA_LINE_OPACITY = 0.92; const VIEWPORT_CAMERA_HIT_PADDING = 0.06; -const VIEWPORT_CAMERA_FORWARD = new Vector3(0, 0, 1); -const VIEWPORT_CAMERA_WORLD_UP = new Vector3(0, 1, 0); const VIEWPORT_CAMERA_BODY_CENTER: CameraWirePoint = [0, 0, -0.52 * VIEWPORT_CAMERA_VISUAL_SCALE]; const VIEWPORT_CAMERA_BODY_SIZE: CameraWirePoint = [ 0.4 * VIEWPORT_CAMERA_VISUAL_SCALE, @@ -123,8 +128,12 @@ function ViewportTransformControls({ const beginUndoBatch = useDirectorStore((state) => state.beginUndoBatch); const endUndoBatch = useDirectorStore((state) => state.endUndoBatch); const setEditingObjectId = usePlaybackStore((state) => state.setEditingObjectId); + // gizmo 必须挂在场景根(而不是场景组)上:场景组带了缩放 / 旋转 / 平移, + // 一旦 gizmo 作为它的子节点,手柄尺寸会被场景缩放放大(屏幕上的手柄变大), + // 且位置会被场景变换叠加两遍。挂到根上后手柄落在物体真实世界坐标,且尺寸不再随场景缩放变化。 + const scene = useThree((state) => state.scene); - return ( + return createPortal( + />, + scene ); } -export function getViewportCameraQuaternion( - position: [number, number, number], - target: [number, number, number] -) { - const origin = new Vector3(...position); - const direction = new Vector3(...target).sub(origin); - if (direction.lengthSq() === 0) return new Quaternion(); - - const forward = direction.normalize(); - const up = - Math.abs(forward.dot(VIEWPORT_CAMERA_WORLD_UP)) > 0.999 - ? new Vector3(0, 0, 1) - : VIEWPORT_CAMERA_WORLD_UP; - const matrix = new Matrix4().lookAt(origin, origin.clone().sub(forward), up); - - return new Quaternion().setFromRotationMatrix(matrix); -} - export function getViewportCameraOpaqueDepthRange() { const zValues = getViewportCameraBodyWireframeLines() .filter((line) => line.part !== "lens") @@ -712,10 +704,7 @@ function ViewportCameraRig({ const cameraHitArea = useMemo(() => getViewportCameraHitArea(), []); const cameraLabelY = useMemo(() => getViewportCameraLabelY(), []); const frustumLines = useMemo(() => getViewportCameraFrustumLines(camera), [camera]); - const cameraQuaternion = useMemo( - () => getViewportCameraQuaternion(camera.transform.position, camera.target), - [camera.target, camera.transform.position] - ); + const cameraQuaternion = useMemo(() => getCameraLookQuaternion(camera.lookRotation), [camera.lookRotation]); useLayoutEffect(() => { groupRef.current?.quaternion?.copy?.(cameraQuaternion); @@ -726,17 +715,21 @@ function ViewportCameraRig({ if (!group) return; const position: [number, number, number] = [group.position.x, group.position.y, group.position.z]; - const forward = VIEWPORT_CAMERA_FORWARD.clone().applyQuaternion(group.quaternion).normalize(); - const currentDistance = new Vector3(...camera.target).distanceTo(group.position); - const nextTarget = group.position.clone().add(forward.multiplyScalar(Math.max(currentDistance, 0.1))); + // 回写的是「水平角 / 俯仰角 / 横滚角」三个度数,按 YXZ 拆,和写入时的合成顺序一致 + const euler = new Euler().setFromQuaternion(group.quaternion, CAMERA_LOOK_EULER_ORDER); + const lookRotation: [number, number, number] = [ + radiansToDegrees(euler.x), + radiansToDegrees(euler.y), + radiansToDegrees(euler.z), + ]; updateCamera(camera.id, { transform: { position, - rotation: [group.rotation.x, group.rotation.y, group.rotation.z], + rotation: lookRotation, scale: [group.scale.x, group.scale.y, group.scale.z], }, - target: [nextTarget.x, nextTarget.y, nextTarget.z], + lookRotation, }); } @@ -751,6 +744,17 @@ function ViewportCameraRig({ selectObject(object?.id ?? null); } + // 相机机身(机身盒 + 镜头 + 片盘)的几何中心在局部坐标约为 + // (0, 0.225·scale, -0.395·scale),即 group 原点(也就是 TransformControls 三轴手柄 + // 的锚点)落在了镜头前方、机身偏下的位置。把模型整体平移该中心的相反向量, + // 让 group 原点与相机本体中心对齐,这样选中相机时三轴手柄会落在相机身上, + // 而不是悬在机位上方/前方很远处。 + const cameraModelOffset: [number, number, number] = [ + 0, + -0.225 * VIEWPORT_CAMERA_VISUAL_SCALE, + 0.395 * VIEWPORT_CAMERA_VISUAL_SCALE, + ]; + const node = ( - {showLabel ? ( - {camera.name} - ) : null} + + {showLabel ? ( + {camera.name} + ) : null} - - - - + + + + - {bodyWireframeLines.map((line, index) => ( - - ))} + {bodyWireframeLines.map((line, index) => ( + + ))} - {frustumLines.map((points, index) => ( - - ))} + {frustumLines.map((points, index) => ( + + ))} + ); diff --git a/src/editor/canvas/ViewportToolbar.test.tsx b/src/editor/canvas/ViewportToolbar.test.tsx index 78ed1ed..019c136 100644 --- a/src/editor/canvas/ViewportToolbar.test.tsx +++ b/src/editor/canvas/ViewportToolbar.test.tsx @@ -5,7 +5,7 @@ import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/ca import { BODY_TYPE_OPTIONS } from "../runtime/mannequin/bodyTypes"; import { createInitialDirectorState, useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; -import { getCameraRigPositionFromViewSnapshot, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry"; +import { getCameraLookRotationFromTarget, getCameraRigPositionFromViewSnapshot, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry"; import { ViewportToolbar } from "./ViewportToolbar"; import { getModelLibraryItems } from "../modelLibrary/modelLibraryCatalog"; @@ -349,7 +349,8 @@ it("keeps add role and add camera actions available from the viewport capsule", expect(state.project.cameras[1].fov).toBe(64); expect(state.project.cameras[1].transform.position).toEqual(getCameraRigPositionFromViewSnapshot(snapshot)); expect(getCameraViewSnapshotFromShot(state.project.cameras[1]).position).toEqual(snapshot.position); - expect(state.project.cameras[1].target).toEqual([0, 1, -2]); + // 从视口新建的机位:把「视口位置 → 视口注视点」换算成注视朝向角 + expect(state.project.cameras[1].lookRotation).toEqual(getCameraLookRotationFromTarget(snapshot.position, snapshot.target)); }); it("does not show operation feedback text on the right side of the viewport capsule", async () => { diff --git a/src/editor/canvas/sceneTransform.ts b/src/editor/canvas/sceneTransform.ts index 289b8f6..2e6f3ba 100644 --- a/src/editor/canvas/sceneTransform.ts +++ b/src/editor/canvas/sceneTransform.ts @@ -1,3 +1,4 @@ +import { Euler, Matrix4, Quaternion, Vector3 } from "three"; import { degreesRotationToRadians, type SceneSettings } from "../schema/directorProject"; /** 场景组使用的 Three 变换(position / rotation / scale)。 */ @@ -24,3 +25,18 @@ export function getSceneGroupTransform(scene: SceneSettings): SceneGroupTransfor scale: [scene.scale, scene.scale, scene.scale], }; } + +/** + * 把 SceneSettings 转换成场景组的 THREE 世界矩阵。 + * 机位线框 / 三轴 gizmo 都画在这个场景组里,世界坐标 = 该矩阵 × 局部坐标; + * 「机位位置 X/Y/Z」面板、切到机位视角时的渲染相机都要落在世界空间, + * 需要拿这层矩阵把局部坐标换算过去(否则场景一缩放/旋转,xyz 越大越错位)。 + */ +export function getSceneGroupMatrix(scene: SceneSettings): Matrix4 { + const transform = getSceneGroupTransform(scene); + return new Matrix4().compose( + new Vector3(...transform.position), + new Quaternion().setFromEuler(new Euler(...transform.rotation)), + new Vector3(...transform.scale) + ); +} diff --git a/src/editor/io/videoExport.test.ts b/src/editor/io/videoExport.test.ts index 2f4b733..775b96f 100644 --- a/src/editor/io/videoExport.test.ts +++ b/src/editor/io/videoExport.test.ts @@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest"; import { Object3D, PerspectiveCamera, Scene, Vector2 } from "three"; import { usePlaybackStore } from "../runtime/playbackStore"; import { useDirectorStore } from "../store/directorStore"; +import { getCameraLookRotationFromTarget } from "../schema/cameraGeometry"; import { exportVideo } from "./videoExport"; vi.mock("mp4-muxer", () => ({ @@ -125,7 +126,7 @@ function placeCameraShotAt(position: [number, number, number], target: [number, const shot = store.project.cameras[0]; store.updateCamera(shot.id, { transform: { position, rotation: [0, 0, 0], scale: [1, 1, 1] }, - target, + lookRotation: getCameraLookRotationFromTarget(position, target), fov, }); store.setActiveCamera(shot.id); @@ -172,7 +173,7 @@ it("lets the camera track win over the static shot pose in camera-view exports", // 给它加一条运镜轨道:0s 处机位在 [5, 1, 0],和机位自身的 [0, 3, 9] 明显不同 useDirectorStore.getState().addCameraKeyframe(shotId, 0, { position: [5, 1, 0], - target: [0, 1, 0], + lookRotation: getCameraLookRotationFromTarget([5, 1, 0], [0, 1, 0]), fov: 40, }); const bridge = createFakeBridge(); diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index 98f52de..81e22f3 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -6,7 +6,7 @@ import { usePlaybackStore } from "../runtime/playbackStore"; import { getVideoExportContext } from "./videoExportBridge"; import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture"; import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms"; -import { getCameraViewSnapshotFromShot, type CameraViewSnapshot } from "../schema/cameraGeometry"; +import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, type CameraViewSnapshot } from "../schema/cameraGeometry"; import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio"; import { normalizeTimelineFps } from "../schema/timeline"; @@ -66,7 +66,7 @@ function waitNextFrame(): Promise { */ function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: CameraViewSnapshot) { camera.position.set(view.position[0], view.position[1], view.position[2]); - camera.lookAt(view.target[0], view.target[1], view.target[2]); + camera.quaternion.copy(getCameraRenderQuaternion(view.lookRotation)); if (Math.abs(camera.fov - view.fov) > 0.001) { camera.fov = view.fov; } @@ -189,7 +189,11 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { view === "camera" ? exportState.project.cameras.find((item) => item.id === exportState.project.activeCameraId) ?? null : null; - const activeCameraView = activeCameraShot ? getCameraViewSnapshotFromShot(activeCameraShot) : null; + // 机位线框 / 三轴 gizmo 都在场景组里,导出相机也要落在世界坐标(场景变换之内), + // 否则场景一缩放 / 旋转,导出画面里机位所在的世界位置和编辑器里看到的对不上。 + const activeCameraView = activeCameraShot + ? getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(activeCameraShot), exportState.project.scene) + : null; const frameDurationUs = Math.round(1_000_000 / fps); try { diff --git a/src/editor/panels/CameraPanel.test.tsx b/src/editor/panels/CameraPanel.test.tsx index 051cdfe..72af9e7 100644 --- a/src/editor/panels/CameraPanel.test.tsx +++ b/src/editor/panels/CameraPanel.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, vi } from "vitest"; import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/captureBridge"; import { createInitialDirectorState, useDirectorStore } from "../store/directorStore"; +import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraLookRotationFromTarget } from "../schema/cameraGeometry"; import { CameraPanel } from "./CameraPanel"; function seedCameraCapture() { @@ -98,7 +99,9 @@ it("renders the approved camera panel fields", () => { expect(screen.getByLabelText("切换机位")).toBeInTheDocument(); expect(screen.getByLabelText("机位位置 X")).toBeInTheDocument(); expect(screen.getByLabelText("注视目标模式")).toBeInTheDocument(); - expect(screen.getByLabelText("注视坐标 X")).toBeInTheDocument(); + expect(screen.getByLabelText("机位水平角")).toBeInTheDocument(); + expect(screen.getByLabelText("机位俯仰角")).toBeInTheDocument(); + expect(screen.getByLabelText("机位横滚角")).toBeInTheDocument(); expect(screen.getByLabelText("机位 FOV")).toBeInTheDocument(); }); @@ -181,7 +184,7 @@ it("renders target mode as the custom dropdown menu", async () => { await user.click(screen.getByLabelText("注视目标模式")); const menu = screen.getByRole("listbox", { name: "注视目标模式" }); - const manualOption = within(menu).getByRole("option", { name: "手动坐标" }); + const manualOption = within(menu).getByRole("option", { name: "手动角度" }); expect(menu).toHaveClass("inspector-dropdown-menu"); expect(manualOption).toHaveClass("is-selected"); @@ -200,7 +203,7 @@ it("lists visible viewport models as camera focus targets and centers on the sel const menu = screen.getByRole("listbox", { name: "注视目标模式" }); const modelNames = within(menu).getAllByRole("option").map((option) => option.textContent); - expect(modelNames).toEqual(["手动坐标", "角色01", "角色02", "立方体"]); + expect(modelNames).toEqual(["手动角度", "角色01", "角色02", "立方体"]); expect(within(menu).queryByRole("option", { name: "机位01" })).not.toBeInTheDocument(); await user.click(within(menu).getByRole("option", { name: "角色02" })); @@ -212,14 +215,18 @@ it("lists visible viewport models as camera focus targets and centers on the sel expect(focusedObject).toBeTruthy(); expect(camera?.targetMode).toBe("object"); expect(camera?.targetObjectId).toBe(focusedObject?.id); - expect(camera?.target).toEqual([-1.25, 0.99, 0]); + // 锁定物体 = 把「机位 → 物体」这条视线换算成注视朝向角 + expect(camera?.lookRotation).toEqual( + getCameraLookRotationFromTarget(camera!.transform.position, [-1.25, 0.99, 0]) + ); expect(screen.getByLabelText("注视目标模式")).toHaveTextContent("角色02"); - expect(screen.getByLabelText("注视坐标 X")).toHaveValue(-1.25); - expect(screen.getByLabelText("注视坐标 Y")).toHaveValue(0.99); - expect(screen.getByLabelText("注视坐标 Z")).toHaveValue(0); + // rotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)] + expect(screen.getByLabelText("机位水平角")).toHaveValue(camera!.lookRotation[1]); + expect(screen.getByLabelText("机位俯仰角")).toHaveValue(camera!.lookRotation[0]); + expect(screen.getByLabelText("机位横滚角")).toHaveValue(camera!.lookRotation[2]); }); -it("resets the camera position and the look-at target from their own title buttons", async () => { +it("resets the camera position and the look angles from their own buttons", async () => { const user = userEvent.setup(); render(); @@ -227,7 +234,12 @@ it("resets the camera position and the look-at target from their own title butto const seededCamera = readCamera(); expect(seededCamera).toBeTruthy(); - ["位置", "注视坐标"].forEach((label) => { + const positionReset = within(screen.getByRole("group", { name: "位置" })).getByRole("button", { name: "重置位置" }); + expect(positionReset).toHaveClass("inspector-axis-reset-button"); + expect(positionReset.querySelector("svg")).toBeInTheDocument(); + + // 三个角度各自一行,行首带重置按钮 + ["水平角", "俯仰角", "横滚角"].forEach((label) => { const button = within(screen.getByRole("group", { name: label })).getByRole("button", { name: `重置${label}` }); expect(button).toHaveClass("inspector-axis-reset-button"); @@ -236,24 +248,32 @@ it("resets the camera position and the look-at target from their own title butto useDirectorStore.getState().updateCamera("cam_1", { transform: { ...seededCamera!.transform, position: [7, 3, 7] }, - target: [1, 2, 3], + lookRotation: [12, 34, 56], }); await user.click(screen.getByRole("button", { name: "重置位置" })); // 机位回到默认机位位置(不是归零:相机压在世界原点会让视线方向退化) expect(readCamera()?.transform.position).toEqual([0, 1.55, 5.4]); - // 只重置位置,注视点保持原样 - expect(readCamera()?.target).toEqual([1, 2, 3]); + // 只重置位置,注视朝向保持原样 + expect(readCamera()?.lookRotation).toEqual([12, 34, 56]); - await user.click(screen.getByRole("button", { name: "重置注视坐标" })); + // 单独重置「俯仰角」→ 归零(水平),不带动水平角 / 横滚角 + await user.click(within(screen.getByRole("group", { name: "俯仰角" })).getByRole("button", { name: "重置俯仰角" })); + expect(readCamera()?.lookRotation).toEqual([0, 34, 56]); - expect(readCamera()?.target).toEqual([0, 1.05, 0]); + await user.click(screen.getByRole("button", { name: "重置注视角度" })); + + // 俯仰角重置为水平 0°,水平角 / 横滚角回到默认机位 + expect(readCamera()?.lookRotation).toEqual([ + 0, + DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation[1], + DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation[2], + ]); expect(readCamera()?.targetMode).toBe("manual"); - expect(screen.getByLabelText("注视坐标 Z")).toHaveValue(0); }); -it("updates camera position and target coordinates across all axes", async () => { +it("updates camera position and look angles across all axes", async () => { const user = userEvent.setup(); render(); @@ -261,14 +281,15 @@ it("updates camera position and target coordinates across all axes", async () => await user.type(screen.getByLabelText("机位位置 Y"), "3.4"); await user.clear(screen.getByLabelText("机位位置 Z")); await user.type(screen.getByLabelText("机位位置 Z"), "7.5"); - await user.clear(screen.getByLabelText("注视坐标 Y")); - await user.type(screen.getByLabelText("注视坐标 Y"), "1.8"); - await user.clear(screen.getByLabelText("注视坐标 Z")); - await user.type(screen.getByLabelText("注视坐标 Z"), "2"); + await user.clear(screen.getByLabelText("机位水平角")); + await user.type(screen.getByLabelText("机位水平角"), "210"); + await user.clear(screen.getByLabelText("机位俯仰角")); + await user.type(screen.getByLabelText("机位俯仰角"), "12"); const camera = useDirectorStore.getState().project.cameras[0]; expect(camera.transform.position).toEqual([0, 3.4, 7.5]); - expect(camera.target).toEqual([0, 1.8, 2]); + // lookRotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)] + expect(camera.lookRotation).toEqual([12, 210, 0]); }); it("captures the current camera preview from the properties tab and shows it in the screenshots overview", async () => { diff --git a/src/editor/panels/CameraPanel.tsx b/src/editor/panels/CameraPanel.tsx index d51245a..7c8b2d6 100644 --- a/src/editor/panels/CameraPanel.tsx +++ b/src/editor/panels/CameraPanel.tsx @@ -1,7 +1,9 @@ -import { Camera, ChevronLeft, ChevronRight, Download, Eye, Images, Send, Trash2, X, ZoomIn, ZoomOut } from "lucide-react"; +import { Camera, ChevronLeft, ChevronRight, Download, Eye, Images, RotateCcw, Send, Trash2, X, ZoomIn, ZoomOut } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { Vector3 } from "three"; import { + InspectorAngleField, InspectorAxisGroup, InspectorPanel, InspectorRangeNumberField, @@ -12,10 +14,15 @@ import { import { requestViewportCapture } from "../io/captureBridge"; import { downloadDataUrl } from "../io/screenshotExport"; import { postDirectorDeskCapturesToHost } from "../io/hostBridge"; -import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT } from "../schema/cameraGeometry"; +import { + DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, + getCameraLookRotationFromTarget, +} from "../schema/cameraGeometry"; import { getDirectorObjectFocusTarget, isCameraFocusableObject } from "../schema/cameraTarget"; import type { DirectorCameraCapture } from "../schema/directorProject"; +import { CAMERA_ROTATION_LIMITS, normalizeCameraRotation } from "../schema/directorProject"; import { useDirectorStore } from "../store/directorStore"; +import { getSceneGroupMatrix } from "../canvas/sceneTransform"; const VIEWER_ZOOM_MIN = 0.25; const VIEWER_ZOOM_MAX = 5; @@ -23,14 +30,22 @@ const VIEWER_ZOOM_STEP = 0.25; /** 可拖动预览窗口的最小尺寸(手动调节时不允许小于该值) */ const VIEWER_DIALOG_MIN_WIDTH = 360; const VIEWER_DIALOG_MIN_HEIGHT = 300; -/** 机位重置值:默认机位的位置 / 注视点(与新建项目里 1 号机位一致) */ +/** 机位重置值:默认机位的位置 / 注视朝向角(与新建项目里 1 号机位一致) */ const DEFAULT_CAMERA_SHOT_POSITION = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.position; -const DEFAULT_CAMERA_SHOT_TARGET = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.target; +const DEFAULT_CAMERA_SHOT_LOOK_ROTATION = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation; +/** 机位「俯仰角」重置为水平 0°(而非默认机位的略微俯视),水平角 / 横滚角仍回到默认机位 */ +const DEFAULT_CAMERA_PITCH_RESET = 0; function replaceAxis(tuple: [number, number, number], axis: 0 | 1 | 2, value: number): [number, number, number] { return tuple.map((item, index) => (index === axis ? value : item)) as [number, number, number]; } +/** 输入为空或非法值时按 0 处理,避免把 NaN 写进工程 */ +function toSafeNumber(value: string) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + export function CameraPanel() { const [activeTab, setActiveTab] = useState<"properties" | "captures">("properties"); const [captureError, setCaptureError] = useState(null); @@ -70,6 +85,16 @@ export function CameraPanel() { const setActiveCamera = useDirectorStore((state) => state.setActiveCamera); const addCameraCaptures = useDirectorStore((state) => state.addCameraCaptures); const updateCamera = useDirectorStore((state) => state.updateCamera); + // 机位线框 / 三轴 gizmo 都画在场景组里(场景变换之内),手柄落在世界坐标; + // 面板里的「机位位置 X/Y/Z」也要显示同一套世界坐标,否则场景一缩放 / 旋转, + // 手柄(世界)和数值(局部)就差得越来越多。编辑时把世界值反算回场景局部坐标再存。 + const scene = useDirectorStore((state) => state.project.scene); + const sceneMatrix = useMemo(() => getSceneGroupMatrix(scene), [scene]); + const sceneMatrixInverse = useMemo(() => sceneMatrix.clone().invert(), [sceneMatrix]); + const localToWorldAxis = (local: [number, number, number], axis: 0 | 1 | 2): number => + Math.round(new Vector3(...local).applyMatrix4(sceneMatrix).toArray()[axis] * 1000) / 1000; + const worldToLocal = (world: [number, number, number]): [number, number, number] => + new Vector3(...world).applyMatrix4(sceneMatrixInverse).toArray() as [number, number, number]; if (!camera) return null; const currentCamera = camera; @@ -393,15 +418,44 @@ export function CameraPanel() { updateCamera(currentCamera.id, { targetMode: "object", targetObjectId: targetObject.id, - target: getDirectorObjectFocusTarget(targetObject), + // 锁定物体:把「机位 → 物体」这条视线换算成注视朝向角 + lookRotation: getCameraLookRotationFromTarget( + currentCamera.transform.position, + getDirectorObjectFocusTarget(targetObject) + ), }); } - function updateManualTarget(axis: 0 | 1 | 2, value: string) { + /** 手改任一角度即退出「锁定物体」,改为手动角度(与轨迹点改朝向角的口径一致) */ + function updateManualLookRotation(axis: 0 | 1 | 2, value: string) { + const nextValue = normalizeCameraRotation(axis, toSafeNumber(value)); updateCamera(currentCamera.id, { targetMode: "manual", targetObjectId: null, - target: replaceAxis(currentCamera.target, axis, Number(value)), + lookRotation: replaceAxis(currentCamera.lookRotation, axis, nextValue), + }); + } + + function resetLookRotation(axis: 0 | 1 | 2) { + // 俯仰角(轴 0)重置为水平 0°,水平角 / 横滚角回到默认机位对应角 + const resetValue = axis === 0 ? DEFAULT_CAMERA_PITCH_RESET : DEFAULT_CAMERA_SHOT_LOOK_ROTATION[axis]; + updateCamera(currentCamera.id, { + targetMode: "manual", + targetObjectId: null, + lookRotation: replaceAxis(currentCamera.lookRotation, axis, resetValue), + }); + } + + function resetAllLookRotation() { + updateCamera(currentCamera.id, { + targetMode: "manual", + targetObjectId: null, + // 俯仰角重置为水平 0°,水平角 / 横滚角回到默认机位 + lookRotation: [ + DEFAULT_CAMERA_PITCH_RESET, + DEFAULT_CAMERA_SHOT_LOOK_ROTATION[1], + DEFAULT_CAMERA_SHOT_LOOK_ROTATION[2], + ], }); } @@ -712,38 +766,44 @@ export function CameraPanel() { { axis: "X", ariaLabel: "机位位置 X", - value: currentCamera.transform.position[0], - onChange: (value) => + value: localToWorldAxis(currentCamera.transform.position, 0), + onChange: (value) => { + const world = new Vector3(...currentCamera.transform.position) + .applyMatrix4(sceneMatrix) + .toArray() as [number, number, number]; + world[0] = toSafeNumber(value); updateCamera(currentCamera.id, { - transform: { - ...currentCamera.transform, - position: replaceAxis(currentCamera.transform.position, 0, Number(value)), - }, - }), + transform: { ...currentCamera.transform, position: worldToLocal(world) }, + }); + }, }, { axis: "Y", ariaLabel: "机位位置 Y", - value: currentCamera.transform.position[1], - onChange: (value) => + value: localToWorldAxis(currentCamera.transform.position, 1), + onChange: (value) => { + const world = new Vector3(...currentCamera.transform.position) + .applyMatrix4(sceneMatrix) + .toArray() as [number, number, number]; + world[1] = toSafeNumber(value); updateCamera(currentCamera.id, { - transform: { - ...currentCamera.transform, - position: replaceAxis(currentCamera.transform.position, 1, Number(value)), - }, - }), + transform: { ...currentCamera.transform, position: worldToLocal(world) }, + }); + }, }, { axis: "Z", ariaLabel: "机位位置 Z", - value: currentCamera.transform.position[2], - onChange: (value) => + value: localToWorldAxis(currentCamera.transform.position, 2), + onChange: (value) => { + const world = new Vector3(...currentCamera.transform.position) + .applyMatrix4(sceneMatrix) + .toArray() as [number, number, number]; + world[2] = toSafeNumber(value); updateCamera(currentCamera.id, { - transform: { - ...currentCamera.transform, - position: replaceAxis(currentCamera.transform.position, 2, Number(value)), - }, - }), + transform: { ...currentCamera.transform, position: worldToLocal(world) }, + }); + }, }, ]} /> @@ -753,44 +813,53 @@ export function CameraPanel() { value={targetSelectValue} onChange={handleTargetSelection} > - + {focusableObjects.map((item) => ( ))} - - // 注视点回到默认机位的注视点,并切回手动模式(与手改任一轴的行为一致) - updateCamera(currentCamera.id, { - targetMode: "manual", - targetObjectId: null, - target: [...DEFAULT_CAMERA_SHOT_TARGET], - }) - } - axes={[ - { - axis: "X", - ariaLabel: "注视坐标 X", - value: currentCamera.target[0], - onChange: (value) => updateManualTarget(0, value), - }, - { - axis: "Y", - ariaLabel: "注视坐标 Y", - value: currentCamera.target[1], - onChange: (value) => updateManualTarget(1, value), - }, - { - axis: "Z", - ariaLabel: "注视坐标 Z", - value: currentCamera.target[2], - onChange: (value) => updateManualTarget(2, value), - }, - ]} + {/* 注视朝向:水平角 / 俯仰角 / 横滚角各一行,语义与轨迹点完全一致 */} + resetLookRotation(1)} + onValueChange={(value) => updateManualLookRotation(1, value)} /> + resetLookRotation(0)} + onValueChange={(value) => updateManualLookRotation(0, value)} + /> + resetLookRotation(2)} + onValueChange={(value) => updateManualLookRotation(2, value)} + /> + item.kind === "character")!; @@ -436,6 +437,49 @@ it("keeps the trajectory keyframe selected when clicking the keyframe itself", ( expect(keyframe).toHaveClass("is-selected"); }); +it("keeps the trajectory point selected when editing it from the trajectory point inspector", () => { + const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + + render( + <> + + + + ); + fireEvent.click(screen.getByRole("button", { name: "轨迹关键帧 2.5s" })); + expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull(); + + const inspector = screen.getByLabelText("轨迹点右侧属性面板"); + expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull(); + + // 「轨迹点」卡片编辑的就是这个点,卡片内任何点击都不算「点到别处」, + // 否则每改一次位置 / 角度,卡片就会被换成角色属性面板。逐一验证卡片内各处: + const insideTargets = [ + // 位置输入框 + screen.getByLabelText("轨迹点位置 X"), + // 角度滑块(拖拽开始) + screen.getByLabelText("轨迹点水平角滑块"), + // 角度数值框 + screen.getByLabelText("轨迹点水平角"), + // 角度重置按钮 + screen.getByLabelText("重置水平角"), + // 卡片标题栏 + screen.getByText("轨迹点"), + // 卡片内摘要文字 + screen.getByText(/所属角色:/), + // 卡片自身(点击留白 / 内边距区域) + inspector, + ]; + + for (const target of insideTargets) { + fireEvent.pointerDown(target, { button: 0, pointerId: 1 }); + expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull(); + expect(screen.getByLabelText("轨迹点右侧属性面板")).toBeInTheDocument(); + } +}); + it("deletes a selected trajectory point with the Delete key", () => { const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); @@ -820,7 +864,7 @@ it("gives the camera track a collapse toggle as well", () => { function addCameraKeyframeAt(cameraId: string, time: number) { useDirectorStore.getState().addCameraKeyframe(cameraId, time, { position: [time, 0, 0], - target: [0, 0, 0], + lookRotation: [0, 0, 0], fov: 50, }); } diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index 8f84779..39b7dba 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -454,8 +454,13 @@ export function TimelinePanel() { if (!selectedTrajectoryPoint) return; function clearTrajectoryPointOnOutsidePointerDown(event: PointerEvent) { + const target = event.target instanceof Element ? event.target : null; + // 点在轨迹关键帧自己身上不算「别处」——那一下是选中它 / 拖它改时间 - if (event.target instanceof Element && event.target.closest(".timeline-keyframe-trajectory")) return; + if (target?.closest(".timeline-keyframe-trajectory")) return; + // 右侧「轨迹点」属性面板编辑的就是这个点,点它(滑块 / 数值框 / 标题栏)也不算别处, + // 否则每改一次位置或角度,选中态就被清掉、面板跳回角色属性卡片。 + if (target?.closest(".trajectory-point-inspector")) return; selectTrajectoryPoint(null, null); @@ -507,7 +512,7 @@ export function TimelinePanel() { if (!selectedShot) throw new Error("no selected shot"); return { position: [...selectedShot.transform.position] as [number, number, number], - target: [...selectedShot.target] as [number, number, number], + lookRotation: [...selectedShot.lookRotation] as [number, number, number], fov: selectedShot.fov, }; } diff --git a/src/editor/runtime/keyframeCommand.ts b/src/editor/runtime/keyframeCommand.ts index aeec71d..8b758d1 100644 --- a/src/editor/runtime/keyframeCommand.ts +++ b/src/editor/runtime/keyframeCommand.ts @@ -26,7 +26,7 @@ export function addKeyframeAtPlayhead(): boolean { directorStore.addCameraKeyframe(shot.id, playhead, { position: [...shot.transform.position] as [number, number, number], - target: [...shot.target] as [number, number, number], + lookRotation: [...shot.lookRotation] as [number, number, number], fov: shot.fov, }); diff --git a/src/editor/runtime/playbackTransforms.test.ts b/src/editor/runtime/playbackTransforms.test.ts index 7640414..fd36cdd 100644 --- a/src/editor/runtime/playbackTransforms.test.ts +++ b/src/editor/runtime/playbackTransforms.test.ts @@ -4,7 +4,7 @@ import type { DirectorObject } from "../schema/directorProject"; import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline"; import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "./playbackTransforms"; import { computeExportSize } from "../io/videoExport"; -import { getViewportCameraQuaternion } from "../canvas/SceneRoot"; +import { getCameraLookQuaternion } from "../schema/cameraGeometry"; function makeTrajectoryCharacter(): DirectorObject { return { @@ -43,8 +43,8 @@ function makeCameraTrack(): CameraTimelineTrack { return { cameraId: "cam-1", keyframes: [ - { id: "ckf-1", time: 0, value: { position: [0, 1, 0], target: [0, 0, 0], fov: 50 } }, - { id: "ckf-2", time: 2, value: { position: [2, 1, 0], target: [0, 0, 0], fov: 60 } }, + { id: "ckf-1", time: 0, value: { position: [0, 1, 0], lookRotation: [0, 0, 0], fov: 50 } }, + { id: "ckf-2", time: 2, value: { position: [2, 1, 0], lookRotation: [0, 90, 0], fov: 60 } }, ], }; } @@ -143,9 +143,8 @@ it("applies camera wireframe position and orientation from the camera track", () applyTimelineTransformsAtTime(scene, [], [makeCameraTrack()], 2, null); expect(wire.position.toArray()).toEqual([2, 1, 0]); - // 朝向使用 SceneRoot 的 getViewportCameraQuaternion 约定(与预览线框一致) - const expectedQuaternion = getViewportCameraQuaternion([2, 1, 0], [0, 0, 0]); - expect(wire.quaternion.equals(expectedQuaternion)).toBe(true); + // 线框姿态直接由注视朝向角合成(模型 +Z = 镜头方向),与预览线框同一套约定 + expect(wire.quaternion.equals(getCameraLookQuaternion([0, 90, 0]))).toBe(true); }); it("overrides the render camera in camera view mode", () => { diff --git a/src/editor/runtime/playbackTransforms.ts b/src/editor/runtime/playbackTransforms.ts index 0f0a84d..7c5fe41 100644 --- a/src/editor/runtime/playbackTransforms.ts +++ b/src/editor/runtime/playbackTransforms.ts @@ -4,7 +4,7 @@ import type { DirectorObject } from "../schema/directorProject"; import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline"; import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline"; import { evaluateTrajectoryMotionAtTime, hasTrajectoryMotion } from "../schema/trajectoryMotion"; -import { getViewportCameraQuaternion } from "../canvas/SceneRoot"; +import { getCameraLookQuaternion, getCameraRenderQuaternion } from "../schema/cameraGeometry"; /** 人物轨迹驱动所需的工程上下文:对象列表 + 时间轴时长。 */ export interface TimelinePlaybackContext { @@ -16,7 +16,7 @@ export interface TimelinePlaybackContext { * 按 playhead 把物体 / 相机线框的关键帧插值结果直接写到场景中的 Three.js 对象。 * SceneRoot 的 group 通过 userData 标记身份: * - directorObjectId → 物体(覆盖 position/rotation/scale) - * - directorCameraId → 导演视图相机线框(覆盖 position + 朝向 target) + * - directorCameraId → 导演视图相机线框(覆盖 position + 注视朝向角) * 正在被 gizmo 拖拽的对象(editingObjectId)会被跳过,避免覆盖编辑中的值。 * 预览(PlaybackTransformSync 的 useFrame)与视频导出(逐帧离线渲染)共用此逻辑, * 保证「所见即所得」。 @@ -82,7 +82,8 @@ export function applyTimelineTransformsAtTime( const value = evaluateCameraTrackAtTime(track, playhead); if (value) { object.position.set(value.position[0], value.position[1], value.position[2]); - object.quaternion.copy(getViewportCameraQuaternion(value.position, value.target)); + // 运镜关键帧里的注视朝向是度数,线框姿态直接由三个角合成 + object.quaternion.copy(getCameraLookQuaternion(value.lookRotation)); } } } @@ -91,7 +92,7 @@ export function applyTimelineTransformsAtTime( /** * 机位视图下:若当前激活相机有运镜轨道,用插值结果覆盖渲染相机的 - * position / lookAt(target) / fov,呈现真正的运镜画面。 + * position / 注视朝向 / fov,呈现真正的运镜画面。 * 返回是否覆盖了相机(导演视角或无轨道时不动)。 * 预览(ActiveCameraPlaybackSync 的 useFrame)与视频导出共用此逻辑。 */ @@ -121,7 +122,7 @@ export function applyActiveCameraViewAtTime({ if (!value) return false; camera.position.set(value.position[0], value.position[1], value.position[2]); - camera.lookAt(value.target[0], value.target[1], value.target[2]); + camera.quaternion.copy(getCameraRenderQuaternion(value.lookRotation)); if (Math.abs(camera.fov - value.fov) > 0.001) { camera.fov = value.fov; camera.updateProjectionMatrix(); diff --git a/src/editor/schema/cameraGeometry.test.ts b/src/editor/schema/cameraGeometry.test.ts index 30f72a2..05b19b4 100644 --- a/src/editor/schema/cameraGeometry.test.ts +++ b/src/editor/schema/cameraGeometry.test.ts @@ -1,8 +1,12 @@ +import { Vector3 } from "three"; import { createDefaultDirectorProject } from "../store/directorStore"; import { + DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, VIEWPORT_CAMERA_FRUSTUM_DEPTH, VIEWPORT_CAMERA_FRUSTUM_FRAME_WIDTH, VIEWPORT_CAMERA_VISUAL_SCALE, + getCameraLookPoint, + getCameraLookRotationFromTarget, getCameraRigPositionFromViewSnapshot, getCameraViewSnapshotFromShot, } from "./cameraGeometry"; @@ -12,7 +16,7 @@ it("places the camera viewing point at the rig position", () => { const viewSnapshot = getCameraViewSnapshotFromShot(camera); expect(viewSnapshot.position).toEqual(camera.transform.position); - expect(viewSnapshot.target).toEqual(camera.target); + expect(viewSnapshot.lookRotation).toEqual(camera.lookRotation); expect(viewSnapshot.fov).toBe(camera.fov); }); @@ -20,13 +24,38 @@ it("returns the rig position from a saved viewport snapshot", () => { const snapshot = { fov: 50, position: [0, 1.62, 3.8] as [number, number, number], - target: [0, 1.2, 0] as [number, number, number], + lookRotation: [0, 0, 0] as [number, number, number], }; const rigPosition = getCameraRigPositionFromViewSnapshot(snapshot); expect(rigPosition).toEqual(snapshot.position); }); +it("converts a look-at point into look angles and back to the same direction", () => { + const position: [number, number, number] = [0, 1.55, 5.4]; + const target: [number, number, number] = [2, 0.8, -1]; + const lookRotation = getCameraLookRotationFromTarget(position, target); + const lookPoint = getCameraLookPoint(position, lookRotation, 3); + + // 沿视线取点:方向必须和「机位 → 注视点」一致 + const expected = new Vector3(...target).sub(new Vector3(...position)).normalize().multiplyScalar(3); + const actual = new Vector3(...lookPoint).sub(new Vector3(...position)); + + expect(actual.x).toBeCloseTo(expected.x, 5); + expect(actual.y).toBeCloseTo(expected.y, 5); + expect(actual.z).toBeCloseTo(expected.z, 5); +}); + +it("reads back the default shot look angles as pitch/yaw/roll in degrees", () => { + const [pitch, yaw, roll] = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation; + + // 默认机位在 [0, 1.55, 5.4] 看向 [0, 1.05, 0]:略微俯视 + 转向 -Z + expect(pitch).toBeGreaterThan(0); + expect(pitch).toBeLessThan(10); + expect(yaw).toBeCloseTo(180, 5); + expect(roll).toBeCloseTo(0, 5); +}); + it("scales the viewport camera viewfinder from one visual scale", () => { // 取景框(机身/镜筒/视锥)全部由这一个视觉缩放值推导,改它即可整体缩放。 // 0.35 是当前标定值;旧测试里写死的 0.8 已与实现不一致。 diff --git a/src/editor/schema/cameraGeometry.ts b/src/editor/schema/cameraGeometry.ts index 7e2ce6f..e97f368 100644 --- a/src/editor/schema/cameraGeometry.ts +++ b/src/editor/schema/cameraGeometry.ts @@ -1,9 +1,16 @@ -import type { DirectorCameraShot } from "./directorProject"; +import { Euler, Matrix4, Quaternion, Vector3 } from "three"; +import { + degreesRotationToRadians, + radiansToDegrees, + type DirectorCameraShot, + type SceneSettings, +} from "./directorProject"; export interface CameraViewSnapshot { fov: number; position: [number, number, number]; - target: [number, number, number]; + /** 注视朝向(度):[俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)] */ + lookRotation: [number, number, number]; } export const VIEWPORT_CAMERA_ASPECT = 16 / 9; @@ -11,20 +18,186 @@ export const VIEWPORT_CAMERA_VISUAL_SCALE = 0.35; export const VIEWPORT_CAMERA_FRUSTUM_DEPTH = 5.2 * VIEWPORT_CAMERA_VISUAL_SCALE; export const VIEWPORT_CAMERA_FRUSTUM_FRAME_WIDTH = 3.2 * VIEWPORT_CAMERA_VISUAL_SCALE; +/** 机位线框模型的镜头方向是自身 +Z(与角色 / 轨迹点「正面朝 +Z」一致) */ +export const CAMERA_LOOK_FORWARD = new Vector3(0, 0, 1); +/** + * 注视朝向三个角的合成顺序:先横滚(绕自身 Z)、再俯仰(绕自身 X)、最后水平(绕世界 Y)。 + * 用 YXZ 而不是默认的 XYZ,是为了让俯仰 / 横滚始终绕镜头自身的轴 —— + * 用 XYZ 时机头转过 180° 后俯仰方向会反过来,调镜头会非常反直觉。 + */ +export const CAMERA_LOOK_EULER_ORDER: "YXZ" = "YXZ"; + +const WORLD_UP = new Vector3(0, 1, 0); +const WORLD_ORIGIN = new Vector3(0, 0, 0); +/** 视线几乎竖直(与世界 up 共线)时换一个参考 up,避免 lookAt 退化 */ +const STEEP_VIEW_UP = new Vector3(0, 0, 1); +/** three 的相机沿自身 -Z 看出去,与线框模型的 +Z 镜头方向差半圈 */ +const RENDER_CAMERA_FLIP = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI); +/** 需要把朝向角换算成一个世界「注视点」时(截图元数据 / 环绕机位)沿视线取的距离 */ +export const DEFAULT_CAMERA_LOOK_DISTANCE = 5; + +/** 默认机位:位置 + 它看向的点(导演视角初始位姿与 1 号机位共用) */ +export const DEFAULT_DIRECTOR_CAMERA_POSITION: [number, number, number] = [0, 1.55, 5.4]; +export const DEFAULT_DIRECTOR_CAMERA_LOOK_POINT: [number, number, number] = [0, 1.05, 0]; + +/** + * 由「机位位置 → 世界注视点」反解出注视朝向角。 + * 锁定物体、从视口新建机位、旧工程迁移都走这里 —— 数据模型里只存角度,不存注视点。 + */ +export function getCameraLookRotationFromTarget( + position: [number, number, number], + target: [number, number, number] +): [number, number, number] { + const direction = new Vector3(...target).sub(new Vector3(...position)); + if (direction.lengthSq() === 0) return [0, 0, 0]; + + const forward = direction.normalize(); + const up = Math.abs(forward.dot(WORLD_UP)) > 0.999 ? STEEP_VIEW_UP : WORLD_UP; + // Matrix4.lookAt 的 +Z 指向「eye - target」:这里要 +Z = 镜头方向,所以把注视点放到反方向 + const matrix = new Matrix4().lookAt(WORLD_ORIGIN, WORLD_ORIGIN.clone().sub(forward), up); + const euler = new Euler().setFromQuaternion(new Quaternion().setFromRotationMatrix(matrix), CAMERA_LOOK_EULER_ORDER); + + return [radiansToDegrees(euler.x), radiansToDegrees(euler.y), radiansToDegrees(euler.z)]; +} + +/** 由「机位位置 → 世界注视点」直接得到线框姿态(+Z 指向注视点) */ +export function getCameraQuaternionFromTarget( + position: [number, number, number], + target: [number, number, number] +): Quaternion { + return getCameraLookQuaternion(getCameraLookRotationFromTarget(position, target)); +} + +/** 把注视朝向角合成成四元数:结果的 +Z 就是镜头看向的方向(线框模型姿态) */ +export function getCameraLookQuaternion(rotation: [number, number, number]): Quaternion { + return new Quaternion().setFromEuler( + new Euler(...degreesRotationToRadians(rotation), CAMERA_LOOK_EULER_ORDER) + ); +} + +/** 渲染相机(three 相机沿自身 -Z 看出去)的姿态:在线框姿态上再绕 Y 转半圈 */ +export function getCameraRenderQuaternion(rotation: [number, number, number]): Quaternion { + const quaternion = getCameraLookQuaternion(rotation).multiply(RENDER_CAMERA_FLIP.clone()); + + // q 与 -q 是同一个姿态:统一取 w ≥ 0 的那一个。 + // 这样同一组角度无论走「角度合成」还是 three 的 lookAt,算出来的四个分量都一致, + // 比对 / 导出时不会因为双重覆盖的符号差而看起来像两个不同的朝向。 + if (quaternion.w >= 0) return quaternion; + + return quaternion.set(-quaternion.x, -quaternion.y, -quaternion.z, -quaternion.w); +} + +/** + * 视线前方 distance 处的世界点。 + * 角度模型本身不需要注视点,但截图元数据、四方位环绕这些「绕着一个点转」的场景仍要一个落点。 + */ +export function getCameraLookPoint( + position: [number, number, number], + rotation: [number, number, number], + distance: number = DEFAULT_CAMERA_LOOK_DISTANCE +): [number, number, number] { + const forward = CAMERA_LOOK_FORWARD.clone().applyQuaternion(getCameraLookQuaternion(rotation)); + const point = new Vector3(...position).add(forward.multiplyScalar(Math.max(distance, 0.0001))); + + return [point.x, point.y, point.z]; +} + +const _slerpFrom = new Quaternion(); +const _slerpTo = new Quaternion(); +const _slerpResult = new Quaternion(); + +/** + * 两帧注视朝向角之间做四元数插值:与轨迹点的朝向插值一致, + * 逐轴线性插值会在角度跨圈时绕远路,转出来的镜头会甩一下。 + */ +export function slerpCameraLookRotation( + a: [number, number, number], + b: [number, number, number], + t: number +): [number, number, number] { + if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) return [a[0], a[1], a[2]]; + + _slerpFrom.setFromEuler(new Euler(...degreesRotationToRadians(a), CAMERA_LOOK_EULER_ORDER)); + _slerpTo.setFromEuler(new Euler(...degreesRotationToRadians(b), CAMERA_LOOK_EULER_ORDER)); + _slerpResult.slerpQuaternions(_slerpFrom, _slerpTo, t); + + const euler = new Euler().setFromQuaternion(_slerpResult, CAMERA_LOOK_EULER_ORDER); + return [radiansToDegrees(euler.x), radiansToDegrees(euler.y), radiansToDegrees(euler.z)]; +} + export const DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT: CameraViewSnapshot = { fov: 50, - position: [0, 1.55, 5.4], - target: [0, 1.05, 0], + position: [...DEFAULT_DIRECTOR_CAMERA_POSITION], + lookRotation: getCameraLookRotationFromTarget(DEFAULT_DIRECTOR_CAMERA_POSITION, DEFAULT_DIRECTOR_CAMERA_LOOK_POINT), }; export function getCameraViewSnapshotFromShot(camera: DirectorCameraShot): CameraViewSnapshot { return { fov: camera.fov, position: [...camera.transform.position], - target: camera.target, + lookRotation: [...camera.lookRotation], }; } -export function getCameraRigPositionFromViewSnapshot(snapshot: CameraViewSnapshot): [number, number, number] { +/** + * 把「场景内局部坐标的机位视角」换算成「世界坐标的机位视角」。 + * + * 机位线框模型和三轴 gizmo 都画在场景组(sceneGroup)里,所以它们的世界位置 / 朝向是 + * 场景变换(缩放 / 旋转 / 平移)乘到局部坐标 / 朝向上的结果。但「机位位置 X/Y/Z」面板、 + * 切到机位视角时真正生效的渲染相机、以及截图中心点和方位控件都要落在同一套世界坐标里, + * 否则场景一缩放 / 旋转,xyz 越大、世界坐标和局部数值就差得越多。 + * + * 位置:worldPos = sceneMatrix × localPos + * 朝向:worldQuat = sceneQuat × localLookQuat(再拆回 YXZ 三个角) + */ +export function getWorldCameraViewSnapshot( + view: CameraViewSnapshot, + scene: SceneSettings +): CameraViewSnapshot { + const rotationRad = degreesRotationToRadians(scene.rotation); + const sceneMatrix = new Matrix4().compose( + new Vector3(...scene.position), + new Quaternion().setFromEuler(new Euler(...rotationRad)), + new Vector3(scene.scale, scene.scale, scene.scale) + ); + const worldPosition = new Vector3(...view.position).applyMatrix4(sceneMatrix); + + const sceneQuat = new Quaternion().setFromEuler(new Euler(...rotationRad)); + const worldLookQuat = sceneQuat.multiply(getCameraLookQuaternion(view.lookRotation)); + const worldEuler = new Euler().setFromQuaternion(worldLookQuat, CAMERA_LOOK_EULER_ORDER); + const worldLookRotation: [number, number, number] = [ + radiansToDegrees(worldEuler.x), + radiansToDegrees(worldEuler.y), + radiansToDegrees(worldEuler.z), + ]; + + return { + fov: view.fov, + position: [worldPosition.x, worldPosition.y, worldPosition.z], + lookRotation: worldLookRotation, + }; +} + +export function getCameraRigPositionFromViewSnapshot(snapshot: { + position: [number, number, number]; +}): [number, number, number] { return [...snapshot.position]; } + +/** + * 旧工程迁移:早期版本用世界坐标 target 表示注视,读进来时换算成注视朝向角。 + * 已经是角度的机位(lookRotation 存在)原样返回。 + */ +export function migrateLegacyCameraShot(camera: DirectorCameraShot): DirectorCameraShot { + if (Array.isArray(camera.lookRotation)) return camera; + + const legacyTarget = (camera as unknown as { target?: unknown }).target; + const position = camera.transform?.position; + + return { + ...camera, + lookRotation: Array.isArray(legacyTarget) && position + ? getCameraLookRotationFromTarget(position, legacyTarget as [number, number, number]) + : [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation], + }; +} diff --git a/src/editor/schema/directorProject.ts b/src/editor/schema/directorProject.ts index fa7d737..8b7ea1d 100644 --- a/src/editor/schema/directorProject.ts +++ b/src/editor/schema/directorProject.ts @@ -129,17 +129,26 @@ export interface DirectorTrajectoryPointRef { } /** - * 轨迹点朝向角的取值范围(度),下标与 DirectorTrajectoryPoint.rotation 一致: - * - 0 俯仰角 pitch(X):-90 – 90(趴着 ↔ 躺着,物理限位,不是整圈) - * - 1 水平角 yaw(Y):0 – 360(绕身体竖直轴转一整圈) - * - 2 横滚角 roll(Z):-180 – 180(绕身体前后轴侧倾一整圈) + * 朝向角的取值范围(度),下标与 rotation 一致: + * - 0 俯仰角 pitch(X):-90 – 90(趴着 ↔ 躺着 / 镜头俯视 ↔ 仰视,物理限位,不是整圈) + * - 1 水平角 yaw(Y):0 – 360(绕竖直轴转一整圈) + * - 2 横滚角 roll(Z):-180 – 180(绕前后轴侧倾一整圈) + * + * 轨迹点(DirectorTrajectoryPoint.rotation)与机位注视(DirectorCameraShot.lookRotation) + * 共用这一套语义,保证「角色朝向」和「镜头朝向」在面板上的读数是同一套语言。 */ -export const TRAJECTORY_ROTATION_LIMITS: Record<0 | 1 | 2, { min: number; max: number }> = { +export const ORIENTATION_ROTATION_LIMITS: Record<0 | 1 | 2, { min: number; max: number }> = { 0: { min: -90, max: 90 }, 1: { min: 0, max: 360 }, 2: { min: -180, max: 180 }, }; +/** 轨迹点朝向角的取值范围(度):通用朝向角语义的别名 */ +export const TRAJECTORY_ROTATION_LIMITS = ORIENTATION_ROTATION_LIMITS; + +/** 机位注视朝向的取值范围(度):与轨迹点完全一致 */ +export const CAMERA_ROTATION_LIMITS = ORIENTATION_ROTATION_LIMITS; + export const DEGREES_TO_RADIANS = Math.PI / 180; export const RADIANS_TO_DEGREES = 180 / Math.PI; @@ -183,18 +192,24 @@ export function wrapAngleIntoRange(value: number, min: number, max: number): num * - 水平角 / 横滚角是整圈,越界按模收敛(360° ≡ 0°、181° ≡ -179°),端点保留。 * - 非法值按 0 处理。 */ -export function normalizeTrajectoryRotation(axis: 0 | 1 | 2, value: number): number { +export function normalizeOrientationRotation(axis: 0 | 1 | 2, value: number): number { if (!Number.isFinite(value)) return 0; if (axis === 0) { - const { min, max } = TRAJECTORY_ROTATION_LIMITS[0]; + const { min, max } = ORIENTATION_ROTATION_LIMITS[0]; return Math.min(max, Math.max(min, value)); } - const { min, max } = TRAJECTORY_ROTATION_LIMITS[axis]; + const { min, max } = ORIENTATION_ROTATION_LIMITS[axis]; return wrapAngleIntoRange(value, min, max); } +/** 轨迹点朝向角归一化(沿用通用朝向角规则,保留原名便于按语义调用) */ +export const normalizeTrajectoryRotation = normalizeOrientationRotation; + +/** 机位注视朝向角归一化(与轨迹点同一套规则:俯仰角夹紧、水平角 / 横滚角按整圈取模) */ +export const normalizeCameraRotation = normalizeOrientationRotation; + /** 角色 / 道具 / 群众变换旋转的取值范围(度):每个轴都是 0–360° 一整圈。 */ export const TRANSFORM_ROTATION_LIMITS = { min: 0, max: 360 } as const; @@ -254,7 +269,17 @@ export interface DirectorCameraShot { transform: DirectorTransform; targetMode: "manual" | "object"; targetObjectId?: string | null; - target: [number, number, number]; + /** + * 机位的注视朝向(度),顺序与 DirectorTrajectoryPoint.rotation 完全一致: + * [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]。 + * 语义(镜头朝 +Z、机顶朝 +Y): + * - 俯仰角 +90° = 垂直俯视(镜头朝下)、-90° = 垂直仰视(镜头朝上) + * - 水平角 0–360° 是一整圈(360° ≡ 0°) + * - 横滚角 -180° – 180° 是一整圈(180° ≡ -180°) + * 合成顺序是 YXZ(先横滚、再俯仰、最后水平),保证俯仰 / 横滚始终绕镜头自身轴, + * 详细换算见 cameraGeometry。 + */ + lookRotation: [number, number, number]; lastCaptureUrl?: string | null; captures?: DirectorCameraCapture[]; } diff --git a/src/editor/schema/timeline.test.ts b/src/editor/schema/timeline.test.ts index fd664d7..bd58294 100644 --- a/src/editor/schema/timeline.test.ts +++ b/src/editor/schema/timeline.test.ts @@ -30,8 +30,8 @@ import { const V0 = { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] }; const V1 = { position: [10, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] }; -const CAM_A = { position: [0, 0, 0] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 50 }; -const CAM_B = { position: [10, 0, 0] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 70 }; +const CAM_A = { position: [0, 0, 0] as [number, number, number], lookRotation: [0, 0, 0] as [number, number, number], fov: 50 }; +const CAM_B = { position: [10, 0, 0] as [number, number, number], lookRotation: [0, 90, 0] as [number, number, number], fov: 70 }; it("creates a default empty timeline with both track kinds and playback settings", () => { expect(createDefaultTimeline()).toEqual({ @@ -64,9 +64,16 @@ it("returns null for an object track with no keyframes", () => { }); // ---------- 相机插值 ---------- -it("lerps camera position/target/fov linearly between two keyframes", () => { +it("lerps camera position/fov linearly and slerps the look angles between two keyframes", () => { const track = { cameraId: "cam_1", keyframes: [{ id: "k1", time: 0, value: CAM_A }, { id: "k2", time: 2, value: CAM_B }] }; - expect(evaluateCameraTrackAtTime(track, 1)).toEqual({ position: [5, 0, 0], target: [0, 0, 0], fov: 60 }); + const value = evaluateCameraTrackAtTime(track, 1); + + expect(value?.position).toEqual([5, 0, 0]); + expect(value?.fov).toBe(60); + // 水平角 0° → 90° 的中点(四元数插值,走最短弧) + expect(value?.lookRotation[0]).toBeCloseTo(0, 5); + expect(value?.lookRotation[1]).toBeCloseTo(45, 5); + expect(value?.lookRotation[2]).toBeCloseTo(0, 5); }); it("returns null for a camera track with no keyframes", () => { diff --git a/src/editor/schema/timeline.ts b/src/editor/schema/timeline.ts index 326c2f1..b7549c2 100644 --- a/src/editor/schema/timeline.ts +++ b/src/editor/schema/timeline.ts @@ -1,5 +1,6 @@ import { Euler, Quaternion } from "three"; import { degreesToRadians, radiansToDegrees } from "./directorProject"; +import { slerpCameraLookRotation } from "./cameraGeometry"; export type TransformTuple = [number, number, number]; @@ -23,7 +24,8 @@ export interface TimelineTrack { // ===== 相机关键帧 ===== export interface CameraKeyframeValue { position: TransformTuple; - target: TransformTuple; + /** 注视朝向(度):[俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)],与机位的 lookRotation 同一套语义 */ + lookRotation: TransformTuple; fov: number; } export interface CameraTimelineKeyframe { @@ -177,7 +179,8 @@ export function evaluateTrackAtTime(track: TimelineTrack, time: number): Keyfram function lerpCameraValue(a: CameraKeyframeValue, b: CameraKeyframeValue, t: number): CameraKeyframeValue { return { position: lerpTuple(a.position, b.position, t), - target: lerpTuple(a.target, b.target, t), + // 注视朝向是角度,走四元数插值(与轨迹点一致),位置 / FOV 仍按线性插值 + lookRotation: slerpCameraLookRotation(a.lookRotation, b.lookRotation, t), fov: lerp(a.fov, b.fov, t), }; } diff --git a/src/editor/store/directorStore.test.ts b/src/editor/store/directorStore.test.ts index 4808627..96ef199 100644 --- a/src/editor/store/directorStore.test.ts +++ b/src/editor/store/directorStore.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, vi } from "vitest"; import { createDefaultDirectorProject, createInitialDirectorState, useDirectorStore } from "./directorStore"; import { selectRightPanelKind } from "./directorSelectors"; -import { getCameraRigPositionFromViewSnapshot } from "../schema/cameraGeometry"; +import { getCameraLookRotationFromTarget, getCameraRigPositionFromViewSnapshot } from "../schema/cameraGeometry"; import { DEFAULT_GROUND_COLOR } from "../schema/directorProject"; function createMemoryStorage(): Storage { @@ -295,7 +295,7 @@ it("deletes the linked camera track when a camera object is removed", () => { .project.objects.find((item) => item.kind === "camera" && item.linkedCameraId === "cam_1")!; useDirectorStore.getState().addCameraKeyframe("cam_1", 1, { position: [1, 1, 1], - target: [0, 0, 0], + lookRotation: [0, 0, 0], fov: 50, }); expect(useDirectorStore.getState().project.timeline.cameraTracks).toHaveLength(1); @@ -409,15 +409,17 @@ it("adds a new camera from the current viewport snapshot", () => { const state = useDirectorStore.getState(); const addedCamera = state.project.cameras[state.project.cameras.length - 1]; const addedObject = state.project.objects.find((item) => item.linkedCameraId === addedCamera?.id); - const rigPosition = getCameraRigPositionFromViewSnapshot({ + const snapshot = { fov: 62, - position: [4, 3, 2], - target: [0.5, 1.1, -2], - }); + position: [4, 3, 2] as [number, number, number], + target: [0.5, 1.1, -2] as [number, number, number], + }; + const rigPosition = getCameraRigPositionFromViewSnapshot(snapshot); expect(addedCamera?.fov).toBe(62); expect(addedCamera?.transform.position).toEqual(rigPosition); - expect(addedCamera?.target).toEqual([0.5, 1.1, -2]); + // 视口快照是「位置 + 注视点」,落到工程里变成注视朝向角 + expect(addedCamera?.lookRotation).toEqual(getCameraLookRotationFromTarget(snapshot.position, snapshot.target)); expect(addedObject?.transform.position).toEqual(rigPosition); expect(state.project.activeCameraId).toBe(addedCamera?.id); expect(state.selectedObjectId).toBe(addedObject?.id); @@ -433,7 +435,7 @@ it("keeps object-focused cameras centered when the target model moves", () => { useDirectorStore.getState().updateCamera("cam_1", { targetMode: "object", targetObjectId: targetObject!.id, - target: [-1.725, 0.5, 1.15], + lookRotation: [0, 0, 0], }); useDirectorStore.getState().updateObjectTransform(targetObject!.id, { position: [2, 0, -3] }); @@ -441,7 +443,10 @@ it("keeps object-focused cameras centered when the target model moves", () => { expect(camera.targetMode).toBe("object"); expect(camera.targetObjectId).toBe(targetObject!.id); - expect(camera.target).toEqual([2, 0.5, -3]); + // 锁定物体:物体挪了就把「机位 → 物体」的视线重新换算成注视朝向角 + expect(camera.lookRotation).toEqual( + getCameraLookRotationFromTarget(camera.transform.position, [2, 0.5, -3]) + ); }); it("appends camera captures with sequential camera-shot names", () => { @@ -912,7 +917,7 @@ it("addCameraKeyframe inserts a keyframe on the camera's track", () => { const cameraId = useDirectorStore.getState().project.cameras[0].id; const kfId = useDirectorStore.getState().addCameraKeyframe(cameraId, 1, { - position: [3, 3, 3], target: [0, 1, 0], fov: 40, + position: [3, 3, 3], lookRotation: [10, 20, 30], fov: 40, }); expect(typeof kfId).toBe("string"); @@ -921,17 +926,17 @@ it("addCameraKeyframe inserts a keyframe on the camera's track", () => { expect(track?.keyframes[0].value.fov).toBe(40); }); -it("applyCameraKeyframeToShot writes position/target/fov onto the camera shot", () => { +it("applyCameraKeyframeToShot writes position/look rotation/fov onto the camera shot", () => { useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never); const cameraId = useDirectorStore.getState().project.cameras[0].id; useDirectorStore.getState().applyCameraKeyframeToShot(cameraId, { - position: [8, 2, 9], target: [1, 1, 1], fov: 33, + position: [8, 2, 9], lookRotation: [5, 180, 15], fov: 33, }); const shot = useDirectorStore.getState().project.cameras.find((c) => c.id === cameraId); expect(shot?.transform.position).toEqual([8, 2, 9]); - expect(shot?.target).toEqual([1, 1, 1]); + expect(shot?.lookRotation).toEqual([5, 180, 15]); expect(shot?.fov).toBe(33); }); @@ -939,7 +944,7 @@ it("removeCameraKeyframe drops the empty camera track", () => { useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never); const cameraId = useDirectorStore.getState().project.cameras[0].id; const kfId = useDirectorStore.getState().addCameraKeyframe(cameraId, 1, { - position: [0, 0, 0], target: [0, 0, 0], fov: 50, + position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 50, }); useDirectorStore.getState().removeCameraKeyframe(cameraId, kfId); @@ -1006,7 +1011,7 @@ it("clearAllKeyframes empties both object and camera tracks", () => { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], }); useDirectorStore.getState().addCameraKeyframe(cameraId, 1, { - position: [0, 0, 0], target: [0, 0, 0], fov: 50, + position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 50, }); useDirectorStore.getState().clearAllKeyframes(); diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index 4b2231b..e132764 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -30,6 +30,8 @@ import { DEFAULT_CHARACTER_BODY_TYPE, normalizeBodyType } from "../runtime/manne import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraRigPositionFromViewSnapshot, + getCameraLookRotationFromTarget, + migrateLegacyCameraShot, } from "../schema/cameraGeometry"; import type { ViewportAspectRatio } from "../schema/viewportAspectRatio"; import { @@ -215,7 +217,7 @@ export interface DirectorActions { cameraId: string, patch: Partial & { transform?: DirectorTransform; - target?: [number, number, number]; + lookRotation?: [number, number, number]; } ) => void; // 物体关键帧 @@ -517,8 +519,57 @@ function withSceneDefaults(project: DirectorProject): DirectorProject { return { ...project, scene: nextScene }; } +/** + * 旧工程里机位的注视是一个世界坐标 target:统一换算成注视朝向角 + * (水平角 / 俯仰角 / 横滚角),运镜关键帧同理。 + */ +function withCameraLookRotations(project: DirectorProject): DirectorProject { + const cameras = project.cameras ?? []; + const cameraTracks = project.timeline?.cameraTracks ?? []; + const legacyCamera = cameras.some((camera) => Boolean(camera) && !Array.isArray(camera.lookRotation)); + const legacyKeyframe = cameraTracks.some((track) => + (track.keyframes ?? []).some((keyframe) => Boolean(keyframe) && !Array.isArray(keyframe.value?.lookRotation)) + ); + + if (!legacyCamera && !legacyKeyframe) return project; + + return { + ...project, + cameras: cameras.map((camera) => (camera ? migrateLegacyCameraShot(camera) : camera)), + timeline: legacyKeyframe + ? { + ...project.timeline, + cameraTracks: cameraTracks.map((track) => ({ + ...track, + keyframes: (track.keyframes ?? []).map((keyframe) => ({ + ...keyframe, + value: migrateLegacyCameraKeyframeValue(keyframe.value), + })), + })), + } + : project.timeline, + }; +} + +function migrateLegacyCameraKeyframeValue(value: { + position: [number, number, number]; + fov: number; + lookRotation?: [number, number, number]; + target?: [number, number, number]; +}): CameraKeyframeValue { + return { + position: value.position, + fov: value.fov, + lookRotation: Array.isArray(value.lookRotation) + ? value.lookRotation + : Array.isArray(value.target) + ? getCameraLookRotationFromTarget(value.position, value.target) + : [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation], + }; +} + function migrateDirectorProject(project: DirectorProject): DirectorProject { - const withTimelineProject = withSceneDefaults(withTimeline(project)); + const withTimelineProject = withSceneDefaults(withCameraLookRotations(withTimeline(project))); return { ...withTimelineProject, objects: withTimelineProject.objects.map((object) => { @@ -676,7 +727,7 @@ export function createDefaultDirectorProject({ fov: DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.fov, transform: createTransform(getCameraRigPositionFromViewSnapshot(DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT)), targetMode: "manual", - target: DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.target, + lookRotation: [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation], lastCaptureUrl: null, captures: [], }; @@ -928,7 +979,11 @@ function refreshCamerasFocusedOnObject(cameras: DirectorCameraShot[], object: Di camera.targetMode === "object" && camera.targetObjectId === object.id ? { ...camera, - target: getDirectorObjectFocusTarget(object), + // 锁定物体:把「机位 → 物体」这条视线换算成注视朝向角存下来 + lookRotation: getCameraLookRotationFromTarget( + camera.transform.position, + getDirectorObjectFocusTarget(object) + ), } : camera ); @@ -960,7 +1015,10 @@ function refreshCamerasFocusedOnObjects( return { ...camera, - target: getDirectorObjectFocusTarget(targetObject), + lookRotation: getCameraLookRotationFromTarget( + camera.transform.position, + getDirectorObjectFocusTarget(targetObject) + ), }; }); } @@ -1298,8 +1356,8 @@ function pasteClipboardEntries(state: DirectorRuntimeState): DirectorRuntimeStat id: nextCameraId, name: formatSceneItemName("机位", cameraIndex), transform: applyOffsetToTransform(entry.camera.transform, offset), - target: - entry.camera.targetMode === "manual" ? applyPositionOffset(entry.camera.target, offset) : entry.camera.target, + // 注视是朝向角,平移机位不会改变它(锁物体的机位稍后按新位置重算) + lookRotation: [...entry.camera.lookRotation], targetObjectId: targetObjectId ?? entry.camera.targetObjectId ?? null, captures: [], lastCaptureUrl: null, @@ -1354,7 +1412,10 @@ function pasteClipboardEntries(state: DirectorRuntimeState): DirectorRuntimeStat return { ...camera, targetObjectId: mappedTargetObjectId, - target: getDirectorObjectFocusTarget(targetObject), + lookRotation: getCameraLookRotationFromTarget( + camera.transform.position, + getDirectorObjectFocusTarget(targetObject) + ), }; }); const lastPastedObject = pastedObjectIds.length @@ -2348,7 +2409,9 @@ export const useDirectorStore = create((set, get) => { fov: snapshot?.fov ?? 50, transform, targetMode: "manual", - target: snapshot?.target ?? [0, 1.2, 0], + lookRotation: snapshot + ? getCameraLookRotationFromTarget(snapshot.position, snapshot.target) + : [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation], lastCaptureUrl: null, captures: [], }; @@ -2643,7 +2706,7 @@ export const useDirectorStore = create((set, get) => { ...item, ...patch, transform: patch.transform ?? item.transform, - target: patch.target ?? item.target, + lookRotation: patch.lookRotation ?? item.lookRotation, } : item ), @@ -2744,12 +2807,12 @@ export const useDirectorStore = create((set, get) => { }, })), applyCameraKeyframeToShot: (cameraId, value) => { - // 只改 position/target/fov,rotation/scale 保留 shot 原值(相机朝向由 target 决定) + // 只改 position/注视朝向/fov,scale 保留 shot 原值 const camera = get().project.cameras.find((item) => item.id === cameraId); if (!camera) return; get().updateCamera(cameraId, { fov: value.fov, - target: value.target, + lookRotation: value.lookRotation, transform: { ...camera.transform, position: value.position }, }); }, diff --git a/src/styles/index.css b/src/styles/index.css index 56224a6..adf49a7 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -444,6 +444,8 @@ textarea:focus-visible { overflow: hidden; border: 0; border-radius: 0; + /* 屏蔽触摸板 / 触控的浏览器手势(双指、右边缘滑动等),避免右键或触控操作被浏览器当成手势吞掉 */ + touch-action: none; background: rgb(var(--field-rgb)); box-shadow: none; }