feat: 增加轨迹
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user