fix: 优化Gizmo

This commit is contained in:
2026-09-18 03:06:34 +08:00
parent 8645ea3007
commit 9b72f10b43
23 changed files with 787 additions and 254 deletions

View File

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

View File

@@ -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<ScreenshotResult[]> => {
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() {
<div
className={`director-canvas${trajectoryPlacementObjectId ? " is-placing-trajectory" : ""}`}
data-testid="director-canvas"
// 屏蔽在画布上右键(右键平移)弹出的浏览器上下文菜单 / 手势:
// 相机视角下没有 OrbitControls 接管右键,右键拖拽会直接触发浏览器手势,这里统一拦截。
onContextMenu={(event) => event.preventDefault()}
>
<Canvas
camera={{ position: DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position, fov: DEFAULT_DIRECTOR_VIEW_SNAPSHOT.fov }}
@@ -955,16 +1000,23 @@ export function DirectorCanvas() {
snapshotSourceRef={viewportSnapshotSourceRef}
viewMode={viewMode}
/>
{viewMode === "camera" && activeCameraView ? (
{viewMode === "camera" && activeCameraWorldView ? (
<PerspectiveCamera
fov={activeCameraView.fov}
fov={activeCameraWorldView.fov}
makeDefault
position={activeCameraView.position}
onUpdate={(camera) => camera.lookAt(...activeCameraView.target)}
position={activeCameraWorldView.position}
quaternion={
getCameraRenderQuaternion(activeCameraWorldView.lookRotation).toArray() as [
number,
number,
number,
number,
]
}
/>
) : null}
<CanvasCaptureBridge
activeCamera={activeCamera}
activeCamera={activeCameraCaptureView}
bottomPadding={aspectOverlayBottomPadding}
controlsRef={controlsRef}
safeAreaInsets={viewportSafeAreaInsets}

View File

@@ -44,10 +44,10 @@ it("keeps the viewport camera model upright while facing the shot target", () =>
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,
});

View File

@@ -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(
<TransformControls
ref={setControlsRef}
mode={mode}
@@ -138,28 +147,11 @@ function ViewportTransformControls({
onObjectChange={onObjectChange}
translationSnap={translationSnap ?? undefined}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
/>
/>,
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 = (
<group
ref={groupRef}
@@ -760,40 +764,42 @@ function ViewportCameraRig({
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true, directorCameraId: camera.id }}
onClick={selectCameraFromViewport}
>
{showLabel ? (
<ViewportObjectLabel position={[0, cameraLabelY, 0]}>{camera.name}</ViewportObjectLabel>
) : null}
<group position={cameraModelOffset}>
{showLabel ? (
<ViewportObjectLabel position={[0, cameraLabelY, 0]}>{camera.name}</ViewportObjectLabel>
) : null}
<mesh name={`${camera.id}-hit-area`} onClick={selectCameraFromViewport} position={cameraHitArea.position}>
<boxGeometry args={cameraHitArea.args} />
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
</mesh>
<mesh name={`${camera.id}-hit-area`} onClick={selectCameraFromViewport} position={cameraHitArea.position}>
<boxGeometry args={cameraHitArea.args} />
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
</mesh>
{bodyWireframeLines.map((line, index) => (
<Line
key={`${camera.id}-${line.part}-${index}`}
color={VIEWPORT_CAMERA_LINE}
lineWidth={1}
name={`${camera.id}-${line.part}-${index}`}
onClick={selectCameraFromViewport}
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
points={line.points}
transparent
/>
))}
{bodyWireframeLines.map((line, index) => (
<Line
key={`${camera.id}-${line.part}-${index}`}
color={VIEWPORT_CAMERA_LINE}
lineWidth={1}
name={`${camera.id}-${line.part}-${index}`}
onClick={selectCameraFromViewport}
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
points={line.points}
transparent
/>
))}
{frustumLines.map((points, index) => (
<Line
key={`${camera.id}-frustum-${index}`}
color={VIEWPORT_CAMERA_LINE}
lineWidth={1}
name={`${camera.id}-viewfinder-${index}`}
onClick={selectCameraFromViewport}
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
points={points}
transparent
/>
))}
{frustumLines.map((points, index) => (
<Line
key={`${camera.id}-frustum-${index}`}
color={VIEWPORT_CAMERA_LINE}
lineWidth={1}
name={`${camera.id}-viewfinder-${index}`}
onClick={selectCameraFromViewport}
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
points={points}
transparent
/>
))}
</group>
</group>
);

View File

@@ -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 () => {

View File

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

View File

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

View File

@@ -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<void> {
*/
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<void> {
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 {

View File

@@ -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(<CameraPanel />);
@@ -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(<CameraPanel />);
@@ -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 () => {

View File

@@ -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<string | null>(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}
>
<option value="manual">手动坐标</option>
<option value="manual">手动角度</option>
{focusableObjects.map((item) => (
<option key={item.id} value={`object:${item.id}`}>
{item.name}
</option>
))}
</InspectorSelectField>
<InspectorAxisGroup
label="注视坐标"
onReset={() =>
// 注视点回到默认机位的注视点,并切回手动模式(与手改任一轴的行为一致)
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),
},
]}
{/* 注视朝向:水平角 / 俯仰角 / 横滚角各一行,语义与轨迹点完全一致 */}
<InspectorAngleField
label="水平角"
max={CAMERA_ROTATION_LIMITS[1].max}
min={CAMERA_ROTATION_LIMITS[1].min}
numberAriaLabel="机位水平角"
rangeAriaLabel="机位水平角滑块"
value={currentCamera.lookRotation[1]}
onReset={() => resetLookRotation(1)}
onValueChange={(value) => updateManualLookRotation(1, value)}
/>
<InspectorAngleField
label="俯仰角"
max={CAMERA_ROTATION_LIMITS[0].max}
min={CAMERA_ROTATION_LIMITS[0].min}
numberAriaLabel="机位俯仰角"
rangeAriaLabel="机位俯仰角滑块"
value={currentCamera.lookRotation[0]}
onReset={() => resetLookRotation(0)}
onValueChange={(value) => updateManualLookRotation(0, value)}
/>
<InspectorAngleField
label="横滚角"
max={CAMERA_ROTATION_LIMITS[2].max}
min={CAMERA_ROTATION_LIMITS[2].min}
numberAriaLabel="机位横滚角"
rangeAriaLabel="机位横滚角滑块"
value={currentCamera.lookRotation[2]}
onReset={() => resetLookRotation(2)}
onValueChange={(value) => updateManualLookRotation(2, value)}
/>
<button
className="camera-capture-clear-all"
type="button"
aria-label="重置注视角度"
onClick={resetAllLookRotation}
>
<RotateCcw aria-hidden="true" size={14} strokeWidth={1.9} />
<span>重置注视角度</span>
</button>
<InspectorRangeNumberField
label="视野角度 (FOV)"
rangeAriaLabel="机位 FOV 滑杆"

View File

@@ -13,6 +13,7 @@ import {
TimelinePanel,
} from "./TimelinePanel";
import { DEFAULT_TIMELINE_FPS } from "../schema/timeline";
import { TrajectoryPointPanel } from "./TrajectoryPointPanel";
function getDefaultCharacter() {
return useDirectorStore.getState().project.objects.find((item) => 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(
<>
<TimelinePanel />
<TrajectoryPointPanel />
</>
);
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,
});
}

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

@@ -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 已与实现不一致。

View File

@@ -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],
};
}

View File

@@ -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[];
}

View File

@@ -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", () => {

View File

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

View File

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

View File

@@ -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<DirectorCameraShot> & {
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<DirectorStore>((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<DirectorStore>((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<DirectorStore>((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 },
});
},

View File

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