feat: 增加轨迹

This commit is contained in:
2026-09-17 09:09:06 +08:00
parent b9fd289caf
commit e74cf6d28b
42 changed files with 4010 additions and 570 deletions

View File

@@ -1,7 +1,16 @@
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 } from "react";
import { Box3, Matrix4, Quaternion, Vector3, type Group, type Object3D } from "three";
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";
@@ -24,11 +33,19 @@ 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 { 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);
@@ -665,6 +682,7 @@ function ViewportCameraRig({
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(), []);
@@ -698,6 +716,12 @@ function ViewportCameraRig({
}
function selectCameraFromViewport(event: ThreeEvent<MouseEvent>) {
// 打点模式下画布不响应选取(机位也一样),双击地面才落轨迹点
if (trajectoryPlacementObjectId) {
event.stopPropagation();
return;
}
event.stopPropagation();
selectObject(object?.id ?? null);
}
@@ -764,6 +788,164 @@ function ViewportCameraRig({
);
}
/**
* 轨迹打点的拾取平面:不可见但可被射线命中,只在「打点模式」挂载。
*
* 落点坐标不依赖「被打中的是哪块面」:直接用场景组自身的 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 points = owner.trajectoryPoints ?? [];
const color = owner.color ?? TRAJECTORY_FALLBACK_COLOR;
/** 平滑曲线采样点:从「角色自身位置」出发,依次穿过每个路点(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);
}
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}
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>
{/* 实心小球:选中用高亮色,否则用角色色 */}
<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} depthWrite={false} />
</mesh>
</group>
);
})}
</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);
@@ -776,6 +958,8 @@ export function SceneRoot() {
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]);
@@ -798,7 +982,14 @@ export function SceneRoot() {
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;
@@ -808,24 +999,38 @@ export function SceneRoot() {
}
return (
<group
position={scene.position}
rotation={scene.rotation}
scale={[scene.scale, scene.scale, scene.scale]}
>
<group ref={sceneGroupRef} {...getSceneGroupTransform(scene)}>
{scene.showGround ? (
<mesh position={[0, scene.groundHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[200, 200]} />
<meshBasicMaterial
color="#303640"
opacity={getEffectiveGroundOpacity(scene.groundOpacity, Boolean(panoramaAsset))}
polygonOffset
polygonOffsetFactor={1}
polygonOffsetUnits={1}
transparent
/>
</mesh>
<>
<mesh position={[0, scene.groundHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]} />
<meshBasicMaterial
color="#303640"
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) => {