Files
ttstd-3d-director/src/editor/canvas/SceneRoot.tsx

1157 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Html, Line, TransformControls, type TransformControlsProps } from "@react-three/drei";
import { useLoader, type ThreeEvent } from "@react-three/fiber";
import { Suspense, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import {
Box3,
CatmullRomCurve3,
DoubleSide,
Matrix4,
Quaternion,
Vector3,
type Group,
type Object3D,
} from "three";
import type { TransformControls as TransformControlsImpl } from "three-stdlib";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import type {
DirectorAssetRef,
DirectorCameraShot,
DirectorObject,
GeometryPrimitiveType,
} from "../schema/directorProject";
import {
degreesRotationToRadians,
radiansRotationToDegrees,
} from "../schema/directorProject";
import {
VIEWPORT_CAMERA_ASPECT,
VIEWPORT_CAMERA_FRUSTUM_DEPTH,
VIEWPORT_CAMERA_VISUAL_SCALE,
} from "../schema/cameraGeometry";
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTimes } from "../schema/trajectoryMotion";
import type { TransformMode } from "../store/directorStore";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { CharacterModel } from "../runtime/CharacterModel";
import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes";
import { getUE4GroundedLabelY } from "../runtime/ue4Mannequin/ue4MannequinRig";
import { getEffectiveGroundOpacity } from "./panoramaMath";
import { getGroundAxisLines } from "./sceneAxes";
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
import { getSceneGroupTransform } from "./sceneTransform";
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY } from "./viewportCapture";
import { getCrowdAnchorTransform } from "../store/directorStore";
export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath";
const VIEWPORT_CAMERA_LINE = "#A9D8FF";
/** 轨迹:路点标记 / 连线的兜底色(默认沿用角色自身颜色),选中点用高亮色 */
const TRAJECTORY_FALLBACK_COLOR = "#0396FF";
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,
0.4 * VIEWPORT_CAMERA_VISUAL_SCALE,
1 * VIEWPORT_CAMERA_VISUAL_SCALE,
];
const VIEWPORT_CAMERA_BODY_FRONT_Z = VIEWPORT_CAMERA_BODY_CENTER[2] + VIEWPORT_CAMERA_BODY_SIZE[2] / 2;
const VIEWPORT_CAMERA_LENS_TIP: CameraWirePoint = [0, 0, 0.2 * VIEWPORT_CAMERA_VISUAL_SCALE];
const ROLE_LABEL_DISTANCE_FACTOR = 3;
const IMPORTED_MODEL_TARGET_MAX_SIZE = 2;
type CameraWirePoint = [number, number, number];
type CameraWirePointLine = CameraWirePoint[];
type CameraWirePart = "body" | "lens" | "reel";
type CameraWireLine = {
part: CameraWirePart;
points: CameraWirePointLine;
};
type CameraHitArea = {
args: CameraWirePoint;
position: CameraWirePoint;
};
function ViewportObjectLabel({
children,
position,
}: {
children: ReactNode;
position: [number, number, number];
}) {
return (
<Html
center
distanceFactor={ROLE_LABEL_DISTANCE_FACTOR}
pointerEvents="none"
position={position}
sprite
transform
zIndexRange={[0, 1]}
>
<div className="role-label">{children}</div>
</Html>
);
}
function ViewportTransformControls({
mode,
object,
onObjectChange,
translationSnap,
editingId,
}: {
mode: TransformMode;
object: TransformControlsProps["object"];
onObjectChange: TransformControlsProps["onObjectChange"];
translationSnap?: number | null;
editingId?: string;
}) {
const controlsRef = useRef<TransformControlsImpl | null>(null);
const setControlsRef = useCallback((controls: TransformControlsImpl | null) => {
controlsRef.current = controls;
if (controls) {
controls.userData[HIDE_FROM_VIEWPORT_CAPTURE_KEY] = true;
}
}, []);
const beginUndoBatch = useDirectorStore((state) => state.beginUndoBatch);
const endUndoBatch = useDirectorStore((state) => state.endUndoBatch);
const setEditingObjectId = usePlaybackStore((state) => state.setEditingObjectId);
return (
<TransformControls
ref={setControlsRef}
mode={mode}
object={object}
onMouseDown={() => {
beginUndoBatch();
// 拖拽期间标记该对象为“编辑中”,PlaybackTransformSync 会跳过它,避免覆盖拖拽值
setEditingObjectId(editingId ?? null);
}}
onMouseUp={endUndoBatch}
onObjectChange={onObjectChange}
translationSnap={translationSnap ?? undefined}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
/>
);
}
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")
.flatMap((line) => line.points)
.map((point) => point[2]);
return {
minZ: Math.min(...zValues),
maxZ: Math.max(...zValues),
};
}
export function getViewportCameraLabelY() {
const points = getViewportCameraBodyWireframeLines().flatMap((line) => line.points);
const modelTopY = Math.max(...points.map((point) => point[1]));
return modelTopY + VIEWPORT_OBJECT_LABEL_VERTICAL_GAP;
}
export function getImportedModelNormalization(bounds: Box3, targetMaxSize = IMPORTED_MODEL_TARGET_MAX_SIZE) {
if (bounds.isEmpty()) {
return {
position: [0, 0, 0] as [number, number, number],
scale: 1,
};
}
const size = new Vector3();
const center = new Vector3();
bounds.getSize(size);
bounds.getCenter(center);
const maxSize = Math.max(size.x, size.y, size.z);
const scale = Number.isFinite(maxSize) && maxSize > 0 ? targetMaxSize / maxSize : 1;
return {
position: [-center.x * scale, -bounds.min.y * scale, -center.z * scale] as [number, number, number],
scale,
};
}
function createBoxWireframeLines({
center,
size,
}: {
center: CameraWirePoint;
size: CameraWirePoint;
}): CameraWirePointLine[] {
const [cx, cy, cz] = center;
const [width, height, depth] = size;
const x0 = cx - width / 2;
const x1 = cx + width / 2;
const y0 = cy - height / 2;
const y1 = cy + height / 2;
const z0 = cz - depth / 2;
const z1 = cz + depth / 2;
const corners: Record<string, CameraWirePoint> = {
bbl: [x0, y0, z0],
bbr: [x1, y0, z0],
btl: [x0, y1, z0],
btr: [x1, y1, z0],
fbl: [x0, y0, z1],
fbr: [x1, y0, z1],
ftl: [x0, y1, z1],
ftr: [x1, y1, z1],
};
return [
[corners.bbl, corners.bbr],
[corners.bbr, corners.btr],
[corners.btr, corners.btl],
[corners.btl, corners.bbl],
[corners.fbl, corners.fbr],
[corners.fbr, corners.ftr],
[corners.ftr, corners.ftl],
[corners.ftl, corners.fbl],
[corners.bbl, corners.fbl],
[corners.bbr, corners.fbr],
[corners.btr, corners.ftr],
[corners.btl, corners.ftl],
];
}
function createCircleWireframeLine({
center,
radius,
segments = 32,
plane = "xy",
}: {
center: CameraWirePoint;
radius: number;
segments?: number;
plane?: "xy" | "xz" | "yz";
}): CameraWirePointLine {
const [cx, cy, cz] = center;
return Array.from({ length: segments + 1 }, (_, index) => {
const angle = (Math.PI * 2 * index) / segments;
const a = Math.cos(angle) * radius;
const b = Math.sin(angle) * radius;
if (plane === "xz") return [cx + a, cy, cz + b];
if (plane === "yz") return [cx, cy + a, cz + b];
return [cx + a, cy + b, cz];
});
}
function createInvertedTetrahedronLensWireframeLines(): CameraWirePointLine[] {
const backTopLeft: CameraWirePoint = [
-0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_BODY_FRONT_Z,
];
const backTopRight: CameraWirePoint = [
0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_BODY_FRONT_Z,
];
const backBottomRight: CameraWirePoint = [
0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
-0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_BODY_FRONT_Z,
];
const backBottomLeft: CameraWirePoint = [
-0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
-0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_BODY_FRONT_Z,
];
const frontTopLeft: CameraWirePoint = [
-0.25 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.2 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_LENS_TIP[2],
];
const frontTopRight: CameraWirePoint = [
0.25 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.2 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_LENS_TIP[2],
];
const frontBottomRight: CameraWirePoint = [
0.25 * VIEWPORT_CAMERA_VISUAL_SCALE,
-0.2 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_LENS_TIP[2],
];
const frontBottomLeft: CameraWirePoint = [
-0.25 * VIEWPORT_CAMERA_VISUAL_SCALE,
-0.2 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_LENS_TIP[2],
];
return [
[backTopLeft, backTopRight, backBottomRight, backBottomLeft, backTopLeft],
[frontTopLeft, frontTopRight, frontBottomRight, frontBottomLeft, frontTopLeft],
[backTopLeft, frontTopLeft],
[backTopRight, frontTopRight],
[backBottomRight, frontBottomRight],
[backBottomLeft, frontBottomLeft],
];
}
function withCameraPart(part: CameraWirePart, lines: CameraWirePointLine[]): CameraWireLine[] {
return lines.map((points) => ({ part, points }));
}
export function getViewportCameraBodyWireframeLines(): CameraWireLine[] {
return [
...withCameraPart("body", [
...createBoxWireframeLines({ center: VIEWPORT_CAMERA_BODY_CENTER, size: VIEWPORT_CAMERA_BODY_SIZE }),
]),
...withCameraPart("lens", createInvertedTetrahedronLensWireframeLines()),
...withCameraPart("reel", [
createCircleWireframeLine({
center: [0, 0.44 * VIEWPORT_CAMERA_VISUAL_SCALE, -0.78 * VIEWPORT_CAMERA_VISUAL_SCALE],
radius: 0.21 * VIEWPORT_CAMERA_VISUAL_SCALE,
plane: "yz",
}),
createCircleWireframeLine({
center: [0, 0.44 * VIEWPORT_CAMERA_VISUAL_SCALE, -0.34 * VIEWPORT_CAMERA_VISUAL_SCALE],
radius: 0.21 * VIEWPORT_CAMERA_VISUAL_SCALE,
plane: "yz",
}),
]),
];
}
export function getViewportCameraHitArea(): CameraHitArea {
const points = getViewportCameraBodyWireframeLines().flatMap((line) => line.points);
const minX = Math.min(...points.map((point) => point[0]));
const maxX = Math.max(...points.map((point) => point[0]));
const minY = Math.min(...points.map((point) => point[1]));
const maxY = Math.max(...points.map((point) => point[1]));
const minZ = Math.min(...points.map((point) => point[2]));
const maxZ = Math.max(...points.map((point) => point[2]));
return {
args: [
maxX - minX + VIEWPORT_CAMERA_HIT_PADDING * 2,
maxY - minY + VIEWPORT_CAMERA_HIT_PADDING * 2,
maxZ - minZ + VIEWPORT_CAMERA_HIT_PADDING * 2,
],
position: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2],
};
}
function NormalizedImportedObject({ object }: { object: Object3D }) {
const { clone, normalization } = useMemo(() => {
const clonedObject = object.clone(true);
clonedObject.updateMatrixWorld(true);
return {
clone: clonedObject,
normalization: getImportedModelNormalization(new Box3().setFromObject(clonedObject)),
};
}, [object]);
return (
<group
position={normalization.position}
scale={[normalization.scale, normalization.scale, normalization.scale]}
>
<primitive object={clone} />
</group>
);
}
function FbxModel({ url }: { url: string }) {
const object = useLoader(FBXLoader, url);
return <NormalizedImportedObject object={object} />;
}
function ObjModel({ url }: { url: string }) {
const object = useLoader(OBJLoader, url);
return <NormalizedImportedObject object={object} />;
}
function ImportedModel({
fileName,
url,
}: {
fileName: string;
url: string;
}) {
if (/\.fbx$/i.test(fileName)) return <FbxModel url={url} />;
if (/\.obj$/i.test(fileName)) return <ObjModel url={url} />;
return null;
}
function GeometryPrimitiveModel({
color = "#d7e7ff",
geometryType,
}: {
color?: string;
geometryType: GeometryPrimitiveType;
}) {
const material = <meshStandardMaterial color={color} metalness={0.02} roughness={0.68} />;
if (geometryType === "sphere") {
return (
<mesh name="geometry-sphere" position={[0, 0.55, 0]}>
<sphereGeometry args={[0.55, 32, 16]} />
{material}
</mesh>
);
}
if (geometryType === "cylinder") {
return (
<mesh name="geometry-cylinder" position={[0, 0.6, 0]}>
<cylinderGeometry args={[0.45, 0.45, 1.2, 32]} />
{material}
</mesh>
);
}
if (geometryType === "torus") {
return (
<mesh name="geometry-torus" position={[0, 0.14, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.45, 0.14, 16, 48]} />
{material}
</mesh>
);
}
if (geometryType === "cone") {
return (
<mesh name="geometry-cone" position={[0, 0.55, 0]}>
<coneGeometry args={[0.5, 1.1, 32]} />
{material}
</mesh>
);
}
if (geometryType === "pyramid") {
return (
<mesh name="geometry-pyramid" position={[0, 0.55, 0]}>
<coneGeometry args={[0.55, 1.1, 4]} />
{material}
</mesh>
);
}
return (
<mesh name="geometry-box" position={[0, 0.5, 0]}>
<boxGeometry args={[1, 1, 1]} />
{material}
</mesh>
);
}
function ObjectSceneNode({
asset,
item,
selected,
showLabels,
transformMode,
transformable,
translationSnap,
onSelect,
}: {
asset?: DirectorAssetRef;
item: DirectorObject;
selected: boolean;
showLabels: boolean;
transformMode: TransformMode;
transformable: boolean;
translationSnap: number | null;
onSelect?: (item: DirectorObject) => void;
}) {
const groupRef = useRef<Group>(null!);
const [measuredCharacterLabel, setMeasuredCharacterLabel] = useState<{
key: string;
y: number;
} | null>(null);
const updateObjectTransform = useDirectorStore((state) => state.updateObjectTransform);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const motionTime = usePlaybackStore((state) => state.playhead);
const playbackDuration = usePlaybackStore((state) => state.duration);
// 当前选中的轨迹点若属于本角色,则抑制角色自身的变换 gizmo(拖拽交给轨迹点 gizmo)。
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
const activeTrajectoryPoint =
!isPlaying && selectedTrajectoryPoint?.objectId === item.id
? item.trajectoryPoints?.find((point) => point.id === selectedTrajectoryPoint.pointId)
: undefined;
// 带轨迹的角色:位置 / 朝向由「人物轨迹 + 播放头」决定。
// 这里把同一个结果也算进 props,和 PlaybackTransformSync 每帧的写入保持一致,
// 否则 React 每次重渲染都会把角色拉回起始姿态,和轨迹走位互相打架(表现为抖动/闪回)。
const trajectoryMotion = useMemo(
() => evaluateTrajectoryMotionAtTime(item, playbackDuration, motionTime),
[item, playbackDuration, motionTime]
);
const isImportedModel = asset?.sourceType === "model";
const characterLabelKey = `${item.id}:${item.bodyType ?? ""}:${item.characterRig?.rigType ?? ""}`;
const fallbackCharacterLabelY =
item.kind === "character"
? item.characterRig?.rigType === "ue4-mannequin"
? getUE4GroundedLabelY(item.bodyType)
: getGroundedLabelY(item.bodyType)
: 1.25;
const characterLabelY =
measuredCharacterLabel?.key === characterLabelKey ? measuredCharacterLabel.y : fallbackCharacterLabelY;
const handleCharacterLabelAnchorYChange = useCallback(
(anchorY: number) => {
setMeasuredCharacterLabel((current) => {
const nextY = Number(anchorY.toFixed(4));
if (current?.key === characterLabelKey && Math.abs(current.y - nextY) < 0.0001) {
return current;
}
return {
key: characterLabelKey,
y: nextY,
};
});
},
[characterLabelKey]
);
function commitTransformFromViewport() {
const group = groupRef.current;
if (!group) return;
// group.rotation 是弧度,存入数据模型前要转成度数(数据模型里旋转一律用度数)
updateObjectTransform(item.id, {
position: [group.position.x, group.position.y, group.position.z],
rotation: radiansRotationToDegrees([group.rotation.x, group.rotation.y, group.rotation.z]),
scale: [group.scale.x, group.scale.y, group.scale.z],
});
}
const node = (
<group
ref={groupRef}
position={trajectoryMotion ? trajectoryMotion.position : item.transform.position}
// 数据模型里旋转是度数,应用到 Three.js 的 rotation 属性(弧度)前要转换,
// 否则 360° 会被当成 360 弧度 ≈ 57 圈。
rotation={degreesRotationToRadians(trajectoryMotion ? trajectoryMotion.rotation : item.transform.rotation)}
scale={item.transform.scale}
userData={{ directorObjectId: item.id }}
onClick={(event) => {
event.stopPropagation();
onSelect?.(item);
}}
>
{isImportedModel && asset ? (
<Suspense fallback={null}>
<ImportedModel fileName={asset.fileName} url={asset.url} />
</Suspense>
) : item.kind === "character" ? (
<>
<Suspense fallback={null}>
<CharacterModel
bodyType={item.bodyType}
color={item.color}
onLabelAnchorYChange={handleCharacterLabelAnchorYChange}
rigState={item.characterRig}
motionTime={motionTime}
characterId={item.id}
/>
</Suspense>
{showLabels ? (
<ViewportObjectLabel position={[0, characterLabelY, 0]}>{item.name}</ViewportObjectLabel>
) : null}
</>
) : item.kind === "prop" && item.geometryType ? (
<GeometryPrimitiveModel color={item.color} geometryType={item.geometryType} />
) : null}
</group>
);
// 选中了本角色的某个轨迹点时,角色被预览到该路点上,此时不再显示角色自身的变换 gizmo,
// 避免和轨迹点的拖拽 gizmo 重叠;拖拽由轨迹点 gizmo 负责。
const showGizmo = selected && transformable && !isPlaying && !activeTrajectoryPoint;
if (!showGizmo) return node;
return (
<>
{node}
<ViewportTransformControls
mode={transformMode}
object={groupRef}
onObjectChange={commitTransformFromViewport}
translationSnap={transformMode === "translate" ? translationSnap : null}
editingId={item.id}
/>
</>
);
}
function CrowdTransformRig({
crowdId,
objects,
selected,
transformMode,
transformable,
translationSnap,
}: {
crowdId: string;
objects: DirectorObject[];
selected: boolean;
transformMode: TransformMode;
transformable: boolean;
translationSnap: number | null;
}) {
const groupRef = useRef<Group>(null!);
const updateCrowdTransform = useDirectorStore((state) => state.updateCrowdTransform);
const crowdAnchor = useMemo(() => getCrowdAnchorTransform(objects, crowdId), [objects, crowdId]);
function commitCrowdTransformFromViewport() {
const group = groupRef.current;
if (!group) return;
// group.rotation 是弧度,存入数据模型前要转成度数
updateCrowdTransform(crowdId, {
position: [group.position.x, group.position.y, group.position.z],
rotation: radiansRotationToDegrees([group.rotation.x, group.rotation.y, group.rotation.z]),
scale: [group.scale.x, group.scale.y, group.scale.z],
});
}
if (!selected || !transformable || !crowdAnchor) return null;
return (
<>
<group
ref={groupRef}
position={crowdAnchor.position}
rotation={degreesRotationToRadians(crowdAnchor.rotation)}
scale={crowdAnchor.scale}
/>
<ViewportTransformControls
mode={transformMode}
object={groupRef}
onObjectChange={commitCrowdTransformFromViewport}
translationSnap={transformMode === "translate" ? translationSnap : null}
/>
</>
);
}
export function getViewportCameraFrustumLines(
camera: DirectorCameraShot
): Array<[[number, number, number], [number, number, number]]> {
const frameDepth = VIEWPORT_CAMERA_FRUSTUM_DEPTH;
const halfWidth = frameDepth * Math.tan((camera.fov * Math.PI) / 360);
const halfHeight = halfWidth / VIEWPORT_CAMERA_ASPECT;
const topLeft: [number, number, number] = [-halfWidth, halfHeight, frameDepth];
const topRight: [number, number, number] = [halfWidth, halfHeight, frameDepth];
const bottomRight: [number, number, number] = [halfWidth, -halfHeight, frameDepth];
const bottomLeft: [number, number, number] = [-halfWidth, -halfHeight, frameDepth];
return [
[VIEWPORT_CAMERA_LENS_TIP, topLeft],
[VIEWPORT_CAMERA_LENS_TIP, topRight],
[VIEWPORT_CAMERA_LENS_TIP, bottomRight],
[VIEWPORT_CAMERA_LENS_TIP, bottomLeft],
[topLeft, topRight],
[topRight, bottomRight],
[bottomRight, bottomLeft],
[bottomLeft, topLeft],
];
}
function ViewportCameraRig({
camera,
object,
selected,
showLabel,
transformMode,
transformable,
translationSnap,
}: {
camera: DirectorCameraShot;
object?: DirectorObject;
selected: boolean;
showLabel: boolean;
transformMode: TransformMode;
transformable: boolean;
translationSnap: number | null;
}) {
const groupRef = useRef<Group>(null!);
const selectObject = useDirectorStore((state) => state.selectObject);
const updateCamera = useDirectorStore((state) => state.updateCamera);
const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId);
const bodyWireframeLines = useMemo(() => getViewportCameraBodyWireframeLines(), []);
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]
);
useLayoutEffect(() => {
groupRef.current?.quaternion?.copy?.(cameraQuaternion);
}, [cameraQuaternion]);
function commitCameraTransformFromViewport() {
const group = groupRef.current;
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)));
updateCamera(camera.id, {
transform: {
position,
rotation: [group.rotation.x, group.rotation.y, group.rotation.z],
scale: [group.scale.x, group.scale.y, group.scale.z],
},
target: [nextTarget.x, nextTarget.y, nextTarget.z],
});
}
function selectCameraFromViewport(event: ThreeEvent<MouseEvent>) {
// 打点模式下画布不响应选取(机位也一样),双击地面才落轨迹点
if (trajectoryPlacementObjectId) {
event.stopPropagation();
return;
}
event.stopPropagation();
selectObject(object?.id ?? null);
}
const node = (
<group
ref={groupRef}
position={camera.transform.position}
quaternion={cameraQuaternion}
scale={object?.transform.scale ?? [1, 1, 1]}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true, directorCameraId: camera.id }}
onClick={selectCameraFromViewport}
>
{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>
{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
/>
))}
</group>
);
if (!selected || !transformable) return node;
return (
<>
{node}
<ViewportTransformControls
mode={transformMode}
object={groupRef}
onObjectChange={commitCameraTransformFromViewport}
translationSnap={transformMode === "translate" ? translationSnap : null}
editingId={camera.id}
/>
</>
);
}
/**
* 轨迹打点的拾取平面:不可见但可被射线命中,只在「打点模式」挂载。
*
* 落点坐标不依赖「被打中的是哪块面」:直接用场景组自身的 worldToLocal 把世界交点换算成
* 场景组局部坐标,再取其中的 x / z(场景组局部里 y 是高度,平面轨迹固定 y=0)。
* 这样无论射线命中旋转的拾取平面,还是同处地面的网格,横纵轴都正确,
* 不会出现 Z 被算成 0、所有点塌到 X 轴上的问题。
*/
/** 从「场景组局部坐标」里取地面落点 [x, 0, z](y 由轨迹语义固定为 0)。 */
export function getTrajectoryGroundPoint(local: Vector3): [number, number, number] {
return [local.x, 0, local.z];
}
function TrajectoryPlacementPlane({
objectId,
sceneGroupRef,
}: {
objectId: string;
sceneGroupRef: RefObject<Group>;
}) {
const addTrajectoryPoint = useDirectorStore((state) => state.addTrajectoryPoint);
return (
<mesh
name="trajectory-placement-plane"
position={[0, 0, 0]}
rotation={[-Math.PI / 2, 0, 0]}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
onDoubleClick={(event) => {
event.stopPropagation();
// 用「场景组」自身的 worldToLocal 把世界交点换算成场景组局部坐标,
// 不依赖被打中的是哪块面(拾取平面 / 地面都绕 X 轴转 -90° 躺平)。
const group = sceneGroupRef.current;
if (!group) return;
const local = group.worldToLocal(event.point.clone());
addTrajectoryPoint(objectId, getTrajectoryGroundPoint(local));
}}
>
<planeGeometry args={[TRAJECTORY_PLANE_SIZE, TRAJECTORY_PLANE_SIZE]} />
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
</mesh>
);
}
/**
* 一个角色的运动轨迹:每个路点画成贴地实心小球(可点选),相邻路点之间用穿过各点的
* 平滑曲线(Catmull-Rom 样条,视觉上即「贝塞尔曲线」式的圆顺路径)连起来。
* 和网格、坐标轴一样属于编辑器辅助元素,不参与截图 / 视频导出。
*/
function TrajectoryPath({
owner,
selectedPointId,
interactive,
}: {
owner: DirectorObject;
selectedPointId: string | null;
interactive: boolean;
}) {
const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const setPlaying = usePlaybackStore((state) => state.setPlaying);
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
const playbackDuration = usePlaybackStore((state) => state.duration);
const translationSnap = useDirectorStore((state) => (state.project.scene.snapToGrid ? 1 : null));
const selectedGroupRef = useRef<Group>(null!);
const points = owner.trajectoryPoints ?? [];
const color = owner.color ?? TRAJECTORY_FALLBACK_COLOR;
// 只有在「可交互(非打点模式)且非播放中」且确实有选中点时,才给选中点挂拖拽 gizmo
const draggable = interactive && !isPlaying && Boolean(selectedPointId);
/** 平滑曲线采样点:从「角色自身位置」出发,依次穿过每个路点(Catmull-Rom 样条,
* centripetal 型避免路径自交打环)。这样轨迹是从角色身上延伸出来的,而不是凭空
* 从第一个路点开始。 */
const sampledCurve = useMemo(() => {
if (points.length < 1) return null;
// 起点:角色位置(与路点一样抬到网格高度,保证贴地、不出现竖直跳变)
const ownerPoint = new Vector3(
owner.transform.position[0],
owner.transform.position[1] + GROUND_GRID_ELEVATION,
owner.transform.position[2]
);
const curvePoints = [
ownerPoint,
...points.map(
(point) => new Vector3(point.position[0], point.position[1] + GROUND_GRID_ELEVATION, point.position[2])
),
];
const curve = new CatmullRomCurve3(curvePoints, false, "centripetal");
const segments = Math.max(24, (curvePoints.length - 1) * 24);
return curve.getPoints(segments).map((point) => [point.x, point.y, point.z] as [number, number, number]);
}, [points, owner.transform.position]);
if (!points.length) return null;
function selectPoint(pointId: string, event: ThreeEvent<MouseEvent>) {
event.stopPropagation();
selectTrajectoryPoint(owner.id, pointId);
// 轨迹点本身就是时间轴上的关键帧:选中即把指针定位到该点时间,
// 角色按「沿轨迹行走」的插值正好预览到这一点的位置与朝向(0s = 角色起始位置)。
const pointIndex = points.findIndex((point) => point.id === pointId);
if (pointIndex < 0) return;
setPlaying(false);
setPlayhead(getTrajectoryPointTimes(points, playbackDuration)[pointIndex] ?? 0);
}
/**
* 拖拽过程中实时把 gizmo 移动后的坐标写回选中的轨迹点:
* 路点组在场景组局部坐标里抬高了 GROUND_GRID_ELEVATION(小球贴地用),
* 这里把抬升减回去,存回的是轨迹语义坐标(y 默认 0 = 贴地)。
* 写回后「角色预览」会跟着同一份坐标实时移动,所见即所得。
*/
function commitSelectedPoint() {
const group = selectedGroupRef.current;
if (!group || !selectedPointId) return;
updateTrajectoryPoint(owner.id, selectedPointId, {
position: [group.position.x, group.position.y - GROUND_GRID_ELEVATION, group.position.z],
});
}
return (
<group name={`trajectory-${owner.id}`} userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}>
{sampledCurve ? (
<Line
color={color}
lineWidth={1.5}
name={`trajectory-curve-${owner.id}`}
points={sampledCurve}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
/>
) : null}
{points.map((point) => {
const selected = point.id === selectedPointId;
return (
<group
key={point.id}
ref={selected ? selectedGroupRef : undefined}
position={[point.position[0], point.position[1] + GROUND_GRID_ELEVATION, point.position[2]]}
>
{/* 透明大圆盘只为了让小球更好点中(俯视时点地面圆盘比点小球容易) */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
onClick={interactive ? (event) => selectPoint(point.id, event) : undefined}
>
<circleGeometry args={[0.3, 24]} />
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
</mesh>
{/* 实心小球:选中用高亮色,否则用角色色。
这里必须写深度(用材质默认值 true,别改成 false):地面是 transparent 材质,
即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染;
小球一旦不写深度,地面就会整片盖住它 —— 表现就是「地面不透明时看不到轨迹点」。 */}
<mesh
name={`trajectory-point-${point.id}`}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
onClick={interactive ? (event) => selectPoint(point.id, event) : undefined}
>
<sphereGeometry args={[0.1, 18, 14]} />
<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color} />
</mesh>
</group>
);
})}
{/* 选中点挂拖拽 gizmo:拖到哪,角色预览就跟到哪。打点模式 / 播放中 / 无选中点时不挂。 */}
{draggable ? (
<ViewportTransformControls
mode="translate"
object={selectedGroupRef}
onObjectChange={commitSelectedPoint}
translationSnap={translationSnap}
editingId={owner.id}
/>
) : null}
</group>
);
}
/**
* 地面坐标轴:X / Z 两条穿过原点贯通整块地面,颜色与坐标控件(全景球)对应轴一致。
* 抬到和网格同一高度,避免与地面平面 z-fighting;和网格一样不参与截图/视频导出。
*/
function GroundAxes({ y }: { y: number }) {
return (
<>
{getGroundAxisLines(y).map((axis) => (
<Line
key={axis.key}
color={axis.color}
lineWidth={2}
name={`ground-axis-${axis.key}`}
points={axis.points}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
/>
))}
</>
);
}
export function SceneRoot() {
const scene = useDirectorStore((state) => state.project.scene);
const assets = useDirectorStore((state) => state.project.assets);
const objects = useDirectorStore((state) => state.project.objects);
const cameras = useDirectorStore((state) => state.project.cameras);
const panoramaAssetId = useDirectorStore((state) => state.project.panoramaAssetId);
const viewMode = useDirectorStore((state) => state.viewMode);
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
const selectedCrowdId = useDirectorStore((state) => state.selectedCrowdId);
const transformMode = useDirectorStore((state) => state.transformMode);
const selectObject = useDirectorStore((state) => state.selectObject);
const selectCrowd = useDirectorStore((state) => state.selectCrowd);
const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId);
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
const panoramaAsset = assets.find((item) => item.id === panoramaAssetId);
const translationSnap = scene.snapToGrid ? 1 : null;
const assetsById = useMemo(() => new Map(assets.map((item) => [item.id, item])), [assets]);
const cameraObjectsByCameraId = useMemo(() => {
return new Map(
objects
.filter((item) => item.kind === "camera" && item.linkedCameraId)
.map((item) => [item.linkedCameraId as string, item])
);
}, [objects]);
const crowdLocksById = useMemo(() => {
const result = new Map<string, boolean>();
const crowdMembers = objects.filter((item) => item.kind === "character" && item.crowdId);
crowdMembers.forEach((item) => {
const crowdId = item.crowdId as string;
result.set(crowdId, (result.get(crowdId) ?? false) || item.locked);
});
return result;
}, [objects]);
// 场景组根节点:轨迹打点时用它自带的 worldToLocal 把世界交点换算成场景组局部坐标,
// 这样落点不依赖被射线打中的是哪块面,Z(纵轴)永远正确。
const sceneGroupRef = useRef<Group>(null);
function handleObjectSelect(item: DirectorObject) {
// 打点模式下画布不响应选取:这一下双击只用来给目标角色落一个轨迹点
if (trajectoryPlacementObjectId) return;
if (item.kind === "character" && item.crowdId) {
selectCrowd(item.crowdId);
return;
}
selectObject(item.id);
}
return (
<group ref={sceneGroupRef} {...getSceneGroupTransform(scene)}>
{scene.showGround ? (
<>
<mesh position={[0, scene.groundHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]} />
<meshBasicMaterial
color={scene.groundColor}
opacity={getEffectiveGroundOpacity(scene.groundOpacity, Boolean(panoramaAsset))}
polygonOffset
polygonOffsetFactor={1}
polygonOffsetUnits={1}
side={GROUND_SURFACE_SIDE}
transparent
/>
</mesh>
<GroundAxes y={scene.groundHeight + GROUND_GRID_ELEVATION} />
</>
) : null}
{/* 打点模式:整块地面变成落点用的拾取面(不可见) */}
{trajectoryPlacementObjectId ? (
<TrajectoryPlacementPlane objectId={trajectoryPlacementObjectId} sceneGroupRef={sceneGroupRef} />
) : null}
{objects
.filter((item) => (item.trajectoryPoints ?? []).length > 0)
.map((item) => (
<TrajectoryPath
key={`trajectory-${item.id}`}
owner={item}
selectedPointId={selectedTrajectoryPoint?.objectId === item.id ? selectedTrajectoryPoint.pointId : null}
interactive={!trajectoryPlacementObjectId}
/>
))}
{objects
.filter((item) => item.visible && item.kind !== "camera")
.map((item) => {
const asset = item.assetRefId ? assetsById.get(item.assetRefId) : undefined;
return (
<ObjectSceneNode
key={item.id}
asset={asset}
item={item}
selected={item.crowdId ? false : item.id === selectedObjectId}
showLabels={scene.showLabels}
transformMode={transformMode}
transformable={!item.locked}
translationSnap={translationSnap}
onSelect={handleObjectSelect}
/>
);
})}
{Array.from(new Set(objects.map((item) => item.crowdId).filter((item): item is string => typeof item === "string"))).map(
(crowdId) => (
<CrowdTransformRig
key={crowdId}
crowdId={crowdId}
objects={objects}
selected={selectedCrowdId === crowdId}
transformMode={transformMode}
transformable={!(crowdLocksById.get(crowdId) ?? false)}
translationSnap={translationSnap}
/>
)
)}
{viewMode === "director"
? cameras
.map((camera) => ({ camera, object: cameraObjectsByCameraId.get(camera.id) }))
.filter(({ object }) => object?.visible ?? true)
.map(({ camera, object }) => (
<ViewportCameraRig
key={camera.id}
camera={camera}
object={object}
selected={object?.id === selectedObjectId}
showLabel={scene.showLabels}
transformMode={transformMode}
transformable={Boolean(object && !object.locked)}
translationSnap={translationSnap}
/>
))
: null}
</group>
);
}