feat(编辑器): 支持导入 GLB/GLTF 模型并驱动角色动画
- 新增 GLB/GLTF 模型加载与渲染,支持内嵌动画播放 - 通过 IndexedDB 持久化大体积模型二进制,规避 localStorage 限制 - 新增动作片段序列,时间轴可添加、编辑与编排多段动作 - 优化机位视角相机同步,修复角度修改不生效问题 - 扩展骨骼识别,兼容 Mixamo 前缀及 UE4 骨架
This commit is contained in:
1 parent
9d3a5b9bea
commit
38dc27501a
36 files changed
+1899
-427
No files matched your search
@@ -15,12 +15,18 @@ function isEditableShortcutTarget(target: EventTarget | null) {
|
||||
export default function App() {
|
||||
const viewMode = useDirectorStore((state) => state.viewMode);
|
||||
const setViewMode = useDirectorStore((state) => state.setViewMode);
|
||||
const hydratePersistedModelBinaries = useDirectorStore((state) => state.hydratePersistedModelBinaries);
|
||||
|
||||
useEffect(() => {
|
||||
initDirectorDeskHostBridge();
|
||||
window.parent?.postMessage({ type: "storyai:director-desk-ready" }, window.location.origin);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 启动后把持久化的 GLB/GLTF 二进制从 IndexedDB 还原成 objectURL(localStorage 不存大文件)。
|
||||
void hydratePersistedModelBinaries();
|
||||
}, [hydratePersistedModelBinaries]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || isEditableShortcutTarget(event.target)) return;
|
||||
|
||||
@@ -859,6 +859,20 @@ export function DirectorCanvas() {
|
||||
/** 截图桥需要的机位视角:在世界机位视角上补一个 id,用来把截图归到对应机位 */
|
||||
const activeCameraCaptureView =
|
||||
activeCamera && activeCameraWorldView ? { id: activeCamera.id, ...activeCameraWorldView } : undefined;
|
||||
/** 机位视角渲染相机的实例引用:除了给 drei <PerspectiveCamera> 传 position / quaternion / fov prop,
|
||||
* 这里再用 ref 兜底把这三个量直接写回相机实例。否则手改机位角度后,画面要等 R3F 对
|
||||
* makeDefault 相机 prop 的更新时序走到才跟着转,表现为"水平角/俯仰角/横滚角改了没反应"。 */
|
||||
const cameraViewCameraRef = useRef<ThreePerspectiveCamera | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const cameraViewCamera = cameraViewCameraRef.current;
|
||||
if (!cameraViewCamera || viewMode !== "camera" || !activeCameraWorldView) return;
|
||||
cameraViewCamera.position.set(...activeCameraWorldView.position);
|
||||
cameraViewCamera.quaternion.copy(getCameraRenderQuaternion(activeCameraWorldView.lookRotation));
|
||||
if (Math.abs(cameraViewCamera.fov - activeCameraWorldView.fov) > 0.001) {
|
||||
cameraViewCamera.fov = activeCameraWorldView.fov;
|
||||
cameraViewCamera.updateProjectionMatrix();
|
||||
}
|
||||
}, [viewMode, activeCameraWorldView]);
|
||||
const viewportAspectRatio = useDirectorStore((state) => state.viewportAspectRatio);
|
||||
const viewportRuleOfThirdsEnabled = useDirectorStore((state) => state.viewportRuleOfThirdsEnabled);
|
||||
const viewportPanelsCollapsed = useDirectorStore((state) => state.viewportPanelsCollapsed);
|
||||
@@ -1066,6 +1080,7 @@ export function DirectorCanvas() {
|
||||
/>
|
||||
{viewMode === "camera" && activeCameraWorldView ? (
|
||||
<PerspectiveCamera
|
||||
ref={cameraViewCameraRef}
|
||||
fov={activeCameraWorldView.fov}
|
||||
makeDefault
|
||||
position={activeCameraWorldView.position}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Html, Line, TransformControls, type TransformControlsProps } from "@react-three/drei";
|
||||
import { createPortal, useLoader, useThree, type ThreeEvent } from "@react-three/fiber";
|
||||
import { createPortal, useFrame, useLoader, useThree, type ThreeEvent } from "@react-three/fiber";
|
||||
import { Suspense, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
AnimationMixer,
|
||||
Box3,
|
||||
CatmullRomCurve3,
|
||||
DoubleSide,
|
||||
@@ -11,10 +12,14 @@ import {
|
||||
Vector3,
|
||||
type Group,
|
||||
type Object3D,
|
||||
type WebGLRenderer,
|
||||
} 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 { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { clone as cloneGltf } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { configureGltfLoader } from "../runtime/gltf/gltfLoaderConfig";
|
||||
import type {
|
||||
DirectorAssetRef,
|
||||
DirectorCameraShot,
|
||||
@@ -390,6 +395,38 @@ function ObjModel({ url }: { url: string }) {
|
||||
return <NormalizedImportedObject object={object} />;
|
||||
}
|
||||
|
||||
function GltfModel({ url }: { url: string }) {
|
||||
const gl = useThree((state) => state.gl);
|
||||
const gltf = useLoader(GLTFLoader, url, (loader) => configureGltfLoader(loader, gl)) as unknown as GLTF;
|
||||
|
||||
// SkeletonUtils.clone 才能正确克隆蒙皮网格(普通 .clone(true) 会让多实例共享同一骨架)。
|
||||
const scene = useMemo(() => cloneGltf(gltf.scene) as Group, [gltf.scene]);
|
||||
|
||||
const normalization = useMemo(
|
||||
() => getImportedModelNormalization(new Box3().setFromObject(scene)),
|
||||
[scene]
|
||||
);
|
||||
|
||||
// 播放 GLB 内嵌动画(如 Mixamo 导出的走/跑片段):无内嵌动画则保持静态。
|
||||
const mixer = useMemo(() => {
|
||||
if (!gltf.animations || gltf.animations.length === 0) return null;
|
||||
const nextMixer = new AnimationMixer(scene);
|
||||
gltf.animations.forEach((clip) => nextMixer.clipAction(clip).play());
|
||||
return nextMixer;
|
||||
}, [scene, gltf.animations]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (mixer) mixer.update(delta);
|
||||
});
|
||||
|
||||
// 保留 GLB 自带 PBR 材质与贴图,不做强制染色(染色逻辑只针对内置 UE4 角色)。
|
||||
return (
|
||||
<group position={normalization.position} scale={normalization.scale}>
|
||||
<primitive object={scene} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportedModel({
|
||||
fileName,
|
||||
url,
|
||||
@@ -399,6 +436,7 @@ function ImportedModel({
|
||||
}) {
|
||||
if (/\.fbx$/i.test(fileName)) return <FbxModel url={url} />;
|
||||
if (/\.obj$/i.test(fileName)) return <ObjModel url={url} />;
|
||||
if (/\.(glb|gltf)$/i.test(fileName)) return <GltfModel url={url} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -506,6 +544,12 @@ function ObjectSceneNode({
|
||||
[item, playbackDuration, motionTime]
|
||||
);
|
||||
const isImportedModel = asset?.sourceType === "model";
|
||||
// 导入的 GLB/GLTF 人形角色:几何来自资产,需要把对应资产的 url 传给 CharacterModel 去加载。
|
||||
const characterModelAssetUrl = useDirectorStore((state) =>
|
||||
item.characterRig?.modelAssetId
|
||||
? state.project.assets.find((candidate) => candidate.id === item.characterRig!.modelAssetId)?.url
|
||||
: undefined
|
||||
);
|
||||
const characterLabelKey = `${item.id}:${item.bodyType ?? ""}:${item.characterRig?.rigType ?? ""}`;
|
||||
const fallbackCharacterLabelY =
|
||||
item.kind === "character"
|
||||
@@ -575,6 +619,9 @@ function ObjectSceneNode({
|
||||
characterId={item.id}
|
||||
motionStartTime={item.motionStartTime}
|
||||
motionEndTime={item.motionEndTime}
|
||||
motionClips={item.characterRig?.motionClips}
|
||||
duration={playbackDuration}
|
||||
modelAssetUrl={characterModelAssetUrl}
|
||||
/>
|
||||
</Suspense>
|
||||
{showLabels ? (
|
||||
|
||||
@@ -127,6 +127,8 @@ export function ViewportToolbar({
|
||||
const [crowdSpacing, setCrowdSpacing] = useState(String(DEFAULT_CROWD_SPACING));
|
||||
const [activeModelLibraryCategoryId, setActiveModelLibraryCategoryId] =
|
||||
useState<ModelLibraryCategoryId>("convenience");
|
||||
// 「作为角色导入」:把 GLB/GLTF 资产创建为人形角色(可被时间轴动作驱动),而非静态道具。
|
||||
const [addModelAsCharacter, setAddModelAsCharacter] = useState(false);
|
||||
const addImportedAsset = useDirectorStore((state) => state.addImportedAsset);
|
||||
const addObjectFromAsset = useDirectorStore((state) => state.addObjectFromAsset);
|
||||
const removeImportedAsset = useDirectorStore((state) => state.removeImportedAsset);
|
||||
@@ -829,6 +831,16 @@ export function ViewportToolbar({
|
||||
</div>
|
||||
) : (
|
||||
<div className="model-library-grid" role="list" aria-label="模型列表">
|
||||
{activeModelLibraryCategoryId === "my-models" ? (
|
||||
<label className="model-library-character-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addModelAsCharacter}
|
||||
onChange={(event) => setAddModelAsCharacter(event.currentTarget.checked)}
|
||||
/>
|
||||
作为角色导入(导入后可用时间轴动作驱动;仅对 GLB / GLTF 生效)
|
||||
</label>
|
||||
) : null}
|
||||
{activeModelLibraryItems.map((item) => (
|
||||
activeModelLibraryCategoryId === "my-models" ? (
|
||||
<div key={item.id} className="model-library-card-wrap">
|
||||
@@ -837,7 +849,7 @@ export function ViewportToolbar({
|
||||
className="model-library-card"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
addObjectFromAsset(item.id);
|
||||
addObjectFromAsset(item.id, { asCharacter: addModelAsCharacter });
|
||||
setModelLibraryOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -956,7 +968,7 @@ export function ViewportToolbar({
|
||||
className="hidden-file-input"
|
||||
data-testid="scene-local-model-input"
|
||||
tabIndex={-1}
|
||||
accept=".fbx,.obj"
|
||||
accept=".glb,.gltf,.fbx,.obj"
|
||||
type="file"
|
||||
onChange={(event) => void handleLocalModelChange(event, true)}
|
||||
/>
|
||||
@@ -966,7 +978,7 @@ export function ViewportToolbar({
|
||||
className="hidden-file-input"
|
||||
data-testid="library-local-model-input"
|
||||
tabIndex={-1}
|
||||
accept=".fbx,.obj"
|
||||
accept=".glb,.gltf,.fbx,.obj"
|
||||
multiple
|
||||
type="file"
|
||||
onChange={(event) => void handleLocalModelChange(event, false)}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import type { CharacterRigType } from "../schema/directorProject";
|
||||
|
||||
export function detectHumanoidRig(boneNames: string[]): CharacterRigType | null {
|
||||
const set = new Set(boneNames);
|
||||
/** 把骨骼名归一化:去掉常见前缀(mixamorig: 等),转小写,便于比对。 */
|
||||
function normalizeBoneName(name: string): string {
|
||||
return name
|
||||
.replace(/^mixamorig\s*[:_]?/i, "")
|
||||
.replace(/[:_\s-]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
if (set.has("Hips") && set.has("Spine") && set.has("Head")) return "mixamo";
|
||||
if (set.has("J_Bip_C_Hips") && set.has("J_Bip_C_Head")) return "vrm";
|
||||
/** 收集场景中全部骨骼名(去前缀、小写)。 */
|
||||
export function collectBoneNames(object: { traverse: (cb: (o: { isBone?: boolean; name: string }) => void) => void }): string[] {
|
||||
const names: string[] = [];
|
||||
object.traverse((o) => {
|
||||
if (o.isBone) names.push(normalizeBoneName(o.name));
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据骨骼名集合识别人形骨架类型。
|
||||
* 支持带/不带 "mixamorig:" 前缀的 Mixamo,以及 VRM 的 J_Bip_C_* 体系。
|
||||
* 返回 null 表示无法识别(当作普通道具渲染,不接入程序化动作)。
|
||||
*/
|
||||
export function detectHumanoidRig(boneNames: string[] | { traverse: (cb: (o: { isBone?: boolean; name: string }) => void) => void }): CharacterRigType | null {
|
||||
const set = new Set<string>(
|
||||
Array.isArray(boneNames) ? boneNames.map(normalizeBoneName) : collectBoneNames(boneNames)
|
||||
);
|
||||
|
||||
if (set.has("hips") && set.has("spine") && set.has("head")) return "mixamo";
|
||||
if (set.has("jbipchips") && set.has("jbipchead")) return "vrm";
|
||||
if (set.has("pelvis") && set.has("spine") && set.has("head")) return "ue4-mannequin";
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
const LOCAL_MODEL_EXTENSION_RE = /\.(fbx|obj)$/i;
|
||||
import { saveModelBinary } from "./modelBinaryStore";
|
||||
|
||||
const LOCAL_MODEL_EXTENSION_RE = /\.(fbx|obj|glb|gltf)$/i;
|
||||
|
||||
export interface LocalModelFileResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
name: string;
|
||||
url: string;
|
||||
/** GLB/GLTF 二进制在 IndexedDB 中的存储 id;存在时运行时从 IndexedDB 还原 objectURL。 */
|
||||
binaryId?: string;
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
@@ -17,15 +28,46 @@ function readFileAsDataUrl(file: File) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLocalModelFile(file: File) {
|
||||
/**
|
||||
* 读取本地模型文件,返回可在 <primitive url> / useLoader 中直接使用的 url。
|
||||
*
|
||||
* - FBX / OBJ:沿用既有逻辑,读成 data URL(与旧工程兼容,只改了扩展名正则)。
|
||||
* - GLB / GLTF:读成二进制 Blob 存入 IndexedDB(避免 base64 撑爆 localStorage),
|
||||
* 同时返回当前会话可用的 objectURL 与 binaryId;若 IndexedDB 不可用则安静退回 data URL。
|
||||
*/
|
||||
export async function readLocalModelFile(file: File): Promise<LocalModelFileResult> {
|
||||
if (!LOCAL_MODEL_EXTENSION_RE.test(file.name)) {
|
||||
throw new Error("当前仅支持 FBX / OBJ 模型文件");
|
||||
throw new Error("当前仅支持 FBX / OBJ / GLB / GLTF 模型文件");
|
||||
}
|
||||
|
||||
const isGltfFamily = /\.glb$/i.test(file.name) || /\.gltf$/i.test(file.name);
|
||||
|
||||
if (isGltfFamily) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const blob = new Blob([buffer], { type: file.type || "model/gltf-binary" });
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
await saveModelBinary(id, blob);
|
||||
} catch {
|
||||
// IndexedDB 不可用时退回 data URL:当前会话仍可用,只是跨刷新持久化会失效。
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
fileName: file.name,
|
||||
name: file.name.replace(LOCAL_MODEL_EXTENSION_RE, ""),
|
||||
url: URL.createObjectURL(blob),
|
||||
binaryId: id,
|
||||
};
|
||||
}
|
||||
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
name: file.name.replace(LOCAL_MODEL_EXTENSION_RE, ""),
|
||||
url: await readFileAsDataUrl(file),
|
||||
url: dataUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 模型二进制存储(IndexedDB)
|
||||
*
|
||||
* GLB/GLTF 是二进制,转成 base64 塞进 localStorage 极易撑爆配额(localStorage 上限 ~5MB,
|
||||
* 且项目已对 FBX 注释过“可能超出浏览器存储上限”)。这里把二进制存到 IndexedDB,
|
||||
* localStorage 只保留轻量元数据(记录 binaryId),运行时再用 binaryId 还原成 objectURL。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 单例数据库,按需打开;IndexedDB 不可用时所有方法 reject,由调用方兜底(退回 data URL)。
|
||||
* - 与素材生命周期对齐:资产删除时一并 deleteModelBinary。
|
||||
*/
|
||||
|
||||
const DB_NAME = "storyai-3d-director";
|
||||
const STORE_NAME = "model-binaries";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openModelBinaryDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
if (typeof indexedDB === "undefined") {
|
||||
reject(new Error("IndexedDB 不可用"));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("打开模型二进制数据库失败"));
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function saveModelBinary(id: string, blob: Blob): Promise<void> {
|
||||
const db = await openModelBinaryDb();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
tx.objectStore(STORE_NAME).put(blob, id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("保存模型二进制失败"));
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadModelBinary(id: string): Promise<Blob | null> {
|
||||
const db = await openModelBinaryDb();
|
||||
|
||||
return new Promise<Blob | null>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const request = tx.objectStore(STORE_NAME).get(id);
|
||||
request.onsuccess = () => resolve((request.result as Blob | undefined) ?? null);
|
||||
request.onerror = () => reject(request.error ?? new Error("读取模型二进制失败"));
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteModelBinary(id: string): Promise<void> {
|
||||
const db = await openModelBinaryDb();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
tx.objectStore(STORE_NAME).delete(id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("删除模型二进制失败"));
|
||||
});
|
||||
}
|
||||
@@ -51,7 +51,7 @@ it("imports a local OBJ/FBX model from the single local model entry", async () =
|
||||
expect(screen.queryByText("导入道具模型")).not.toBeInTheDocument();
|
||||
|
||||
const input = screen.getByLabelText("导入本地模型");
|
||||
expect(input).toHaveAttribute("accept", ".fbx,.obj");
|
||||
expect(input).toHaveAttribute("accept", ".glb,.gltf,.fbx,.obj");
|
||||
|
||||
const file = new File(["demo"], "football.obj", { type: "model/obj" });
|
||||
await user.upload(input, file);
|
||||
|
||||
@@ -31,7 +31,7 @@ export function AssetImportPanel() {
|
||||
导入本地模型
|
||||
<input
|
||||
aria-label="导入本地模型"
|
||||
accept=".fbx,.obj"
|
||||
accept=".glb,.gltf,.fbx,.obj"
|
||||
type="file"
|
||||
onChange={async (event) => {
|
||||
const input = event.currentTarget;
|
||||
@@ -47,7 +47,7 @@ export function AssetImportPanel() {
|
||||
}}
|
||||
/>
|
||||
<p className="asset-import-status">
|
||||
{latestLocalModel ? `已导入本地模型: ${latestLocalModel.fileName}` : "支持 FBX / OBJ 素模文件"}
|
||||
{latestLocalModel ? `已导入本地模型: ${latestLocalModel.fileName}` : "支持 FBX / OBJ / GLB / GLTF(GLB 支持 Draco/KTX2/Meshopt 压缩)"}
|
||||
</p>
|
||||
</label>
|
||||
<label className="asset-import-item">
|
||||
|
||||
@@ -49,6 +49,9 @@ export function CharacterPanel() {
|
||||
const setCrowdMotionAmplitude = useDirectorStore((state) => state.setCrowdMotionAmplitude);
|
||||
const setMotionEnabled = useDirectorStore((state) => state.setMotionEnabled);
|
||||
const setCrowdMotionEnabled = useDirectorStore((state) => state.setCrowdMotionEnabled);
|
||||
const selectedMotionClipId = useDirectorStore((state) => state.selectedMotionClipId);
|
||||
const updateMotionClip = useDirectorStore((state) => state.updateMotionClip);
|
||||
const addMotionClip = useDirectorStore((state) => state.addMotionClip);
|
||||
const playhead = usePlaybackStore((state) => state.playhead);
|
||||
|
||||
const selection = useMemo(() => {
|
||||
@@ -93,6 +96,13 @@ export function CharacterPanel() {
|
||||
const activeCrowdId = selection.crowdId;
|
||||
const motion: CharacterMotionState = role.characterRig?.motion ?? DEFAULT_MOTION;
|
||||
const isMotionActive = motion.kind !== "none" && motion.enabled;
|
||||
// 动作片段序列:普通角色可叠加多段动作;正在编辑的是「选中的片段」(优先),否则取第一段。
|
||||
const clips = role.characterRig?.motionClips ?? [];
|
||||
const isClipSequence = !isCrowd && clips.length > 0;
|
||||
const activeClip = isClipSequence
|
||||
? clips.find((clip) => clip.id === selectedMotionClipId) ?? clips[0]
|
||||
: null;
|
||||
const motionForEdit = activeClip ?? motion;
|
||||
// 动作当前时刻(播放头)的关节值(度数域)——姿势滑杆显示「动作值 + 偏移」,
|
||||
// 偏移为 0 时滑杆显示的就是动作默认数据本身;拖动时间轴时实时跟随
|
||||
const motionDegrees: Record<string, number> = {};
|
||||
@@ -482,86 +492,124 @@ export function CharacterPanel() {
|
||||
<InspectorSection title="基础动作" className="motion-section">
|
||||
{role.characterRig ? (
|
||||
<>
|
||||
<fieldset className="motion-kind-field">
|
||||
<legend>动作类型</legend>
|
||||
<div className="motion-kind-options">
|
||||
{MOTION_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`motion-kind-option${motion.kind === option.value ? " is-active" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`motion-kind-${role.id}`}
|
||||
value={option.value}
|
||||
aria-label={`角色动作类型 ${option.label}`}
|
||||
checked={motion.kind === option.value}
|
||||
onChange={() => {
|
||||
const kind = option.value as MotionKind;
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotion(selection.crowdId, kind);
|
||||
return;
|
||||
}
|
||||
setMotion(role.id, kind);
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<InspectorRangeNumberField
|
||||
label="速度"
|
||||
rangeAriaLabel="角色动作速度滑杆"
|
||||
numberAriaLabel="角色动作速度"
|
||||
min="0.5"
|
||||
max="2.5"
|
||||
step="0.05"
|
||||
value={motion.speed}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value);
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionSpeed(selection.crowdId, next);
|
||||
return;
|
||||
}
|
||||
setMotionSpeed(role.id, next);
|
||||
}}
|
||||
/>
|
||||
<InspectorRangeNumberField
|
||||
label="幅度"
|
||||
rangeAriaLabel="角色动作幅度滑杆"
|
||||
numberAriaLabel="角色动作幅度"
|
||||
min="0.5"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
value={motion.amplitude}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value);
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionAmplitude(selection.crowdId, next);
|
||||
return;
|
||||
}
|
||||
setMotionAmplitude(role.id, next);
|
||||
}}
|
||||
/>
|
||||
<InspectorCheckboxField
|
||||
label="启用循环"
|
||||
ariaLabel="启用角色动作循环"
|
||||
checked={motion.enabled}
|
||||
onChange={(checked) => {
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionEnabled(selection.crowdId, checked);
|
||||
return;
|
||||
}
|
||||
setMotionEnabled(role.id, checked);
|
||||
}}
|
||||
/>
|
||||
{motion.kind === "idle" ? (
|
||||
<p className="motion-hint">闲置动作:当前仅作为配置项保留,关节驱动在下一阶段接入。</p>
|
||||
) : motion.kind === "run" ? (
|
||||
<p className="motion-hint">跑步动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。</p>
|
||||
) : motion.kind === "walk" ? (
|
||||
<p className="motion-hint">走路动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。</p>
|
||||
{isClipSequence ? (
|
||||
<p className="motion-clip-hint">
|
||||
正在编辑第 {clips.findIndex((clip) => clip.id === activeClip?.id) + 1} 段动作片段(共 {clips.length} 段);
|
||||
在时间轴「动作」轨道点击片段可切换编辑对象。
|
||||
</p>
|
||||
) : null}
|
||||
{clips.length === 0 && !isCrowd ? (
|
||||
<>
|
||||
<p className="motion-hint">该角色还没有动作片段。可到时间轴「动作」轨道点「+」添加,或:</p>
|
||||
<button
|
||||
type="button"
|
||||
className="motion-add-button"
|
||||
onClick={() => addMotionClip(role.id, { start: playhead })}
|
||||
>
|
||||
添加动作片段
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{clips.length > 0 || isCrowd ? (
|
||||
<>
|
||||
<fieldset className="motion-kind-field">
|
||||
<legend>动作类型</legend>
|
||||
<div className="motion-kind-options">
|
||||
{MOTION_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`motion-kind-option${motionForEdit.kind === option.value ? " is-active" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`motion-kind-${role.id}`}
|
||||
value={option.value}
|
||||
aria-label={`角色动作类型 ${option.label}`}
|
||||
checked={motionForEdit.kind === option.value}
|
||||
onChange={() => {
|
||||
const kind = option.value as MotionKind;
|
||||
if (activeClip) {
|
||||
updateMotionClip(role.id, activeClip.id, { kind });
|
||||
return;
|
||||
}
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotion(selection.crowdId, kind);
|
||||
return;
|
||||
}
|
||||
setMotion(role.id, kind);
|
||||
}}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<InspectorRangeNumberField
|
||||
label="速度"
|
||||
rangeAriaLabel="角色动作速度滑杆"
|
||||
numberAriaLabel="角色动作速度"
|
||||
min="0.5"
|
||||
max="2.5"
|
||||
step="0.05"
|
||||
value={motionForEdit.speed}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value);
|
||||
if (activeClip) {
|
||||
updateMotionClip(role.id, activeClip.id, { speed: next });
|
||||
return;
|
||||
}
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionSpeed(selection.crowdId, next);
|
||||
return;
|
||||
}
|
||||
setMotionSpeed(role.id, next);
|
||||
}}
|
||||
/>
|
||||
<InspectorRangeNumberField
|
||||
label="幅度"
|
||||
rangeAriaLabel="角色动作幅度滑杆"
|
||||
numberAriaLabel="角色动作幅度"
|
||||
min="0.5"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
value={motionForEdit.amplitude}
|
||||
onValueChange={(value) => {
|
||||
const next = Number(value);
|
||||
if (activeClip) {
|
||||
updateMotionClip(role.id, activeClip.id, { amplitude: next });
|
||||
return;
|
||||
}
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionAmplitude(selection.crowdId, next);
|
||||
return;
|
||||
}
|
||||
setMotionAmplitude(role.id, next);
|
||||
}}
|
||||
/>
|
||||
<InspectorCheckboxField
|
||||
label="启用循环"
|
||||
ariaLabel="启用角色动作循环"
|
||||
checked={motionForEdit.enabled}
|
||||
onChange={(checked) => {
|
||||
if (activeClip) {
|
||||
updateMotionClip(role.id, activeClip.id, { enabled: checked });
|
||||
return;
|
||||
}
|
||||
if (isCrowd && selection.crowdId) {
|
||||
setCrowdMotionEnabled(selection.crowdId, checked);
|
||||
return;
|
||||
}
|
||||
setMotionEnabled(role.id, checked);
|
||||
}}
|
||||
/>
|
||||
{motionForEdit.kind === "idle" ? (
|
||||
<p className="motion-hint">闲置动作:当前仅作为配置项保留,关节驱动在下一阶段接入。</p>
|
||||
) : motionForEdit.kind === "run" ? (
|
||||
<p className="motion-hint">跑步动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。</p>
|
||||
) : motionForEdit.kind === "walk" ? (
|
||||
<p className="motion-hint">走路动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -320,7 +320,9 @@ it("adds a character track with motion and trajectory sub-tracks", () => {
|
||||
expect(container.querySelector(".timeline-character-track")).toBeInTheDocument();
|
||||
expect(screen.getByText("动作")).toBeInTheDocument();
|
||||
expect(screen.getByText("轨迹")).toBeInTheDocument();
|
||||
expect(screen.getByText("无动作")).toBeInTheDocument();
|
||||
// 角色默认没有动作片段,动作子轨道提示并暴露「添加动作片段」按钮
|
||||
expect(screen.getByText(/暂无动作/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /添加动作片段/ })).toBeInTheDocument();
|
||||
// 两个轨迹点把 5s 时间轴等分成 2 段 → 关键帧落在 2.5s / 5s
|
||||
expect(screen.getByRole("button", { name: "轨迹关键帧 2.5s" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "轨迹关键帧 5.0s" })).toBeInTheDocument();
|
||||
@@ -357,7 +359,8 @@ it("does not create a transform track when adding a keyframe to a selected chara
|
||||
|
||||
it("shows the character motion kind on the motion sub-track", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().setMotion(character.id, "walk");
|
||||
// 动作轨道通过添加动作片段(动作序列)承载基础动作,而非旧的单一段落字段
|
||||
useDirectorStore.getState().addMotionClip(character.id, { kind: "walk" });
|
||||
|
||||
render(<TimelinePanel />);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Pause,
|
||||
PersonStanding,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
SkipBack,
|
||||
@@ -74,10 +75,11 @@ interface SelectedKeyframe {
|
||||
type TimelineTimeDragOwner =
|
||||
| { kind: "trajectory"; objectId: string; pointId: string }
|
||||
| { kind: "camera"; cameraId: string }
|
||||
| { kind: "motion"; objectId: string };
|
||||
| { kind: "motion"; objectId: string }
|
||||
| { kind: "motionClip"; objectId: string; clipId: string };
|
||||
|
||||
/** 时间轴片段(人物轨迹条 / 运镜片段条 / 动作片段条)的稳定标识 */
|
||||
function getTimelineClipKey(kind: "trajectory" | "camera" | "motion", id: string) {
|
||||
function getTimelineClipKey(kind: "trajectory" | "camera" | "motion" | "motionClip", id: string) {
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
@@ -329,6 +331,13 @@ export function TimelinePanel() {
|
||||
const setMotionClipStart = useDirectorStore((state) => state.setMotionClipStart);
|
||||
const setMotionClipTrimStart = useDirectorStore((state) => state.setMotionClipTrimStart);
|
||||
const setMotionClipEnd = useDirectorStore((state) => state.setMotionClipEnd);
|
||||
const addMotionClip = useDirectorStore((state) => state.addMotionClip);
|
||||
const updateMotionClip = useDirectorStore((state) => state.updateMotionClip);
|
||||
const updateMotionClipTime = useDirectorStore((state) => state.updateMotionClipTime);
|
||||
const removeMotionClip = useDirectorStore((state) => state.removeMotionClip);
|
||||
const selectMotionClip = useDirectorStore((state) => state.selectMotionClip);
|
||||
const selectedMotionClipId = useDirectorStore((state) => state.selectedMotionClipId);
|
||||
const selectObject = useDirectorStore((state) => state.selectObject);
|
||||
const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder);
|
||||
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
|
||||
const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder);
|
||||
@@ -840,7 +849,7 @@ export function TimelinePanel() {
|
||||
* 任何一次片段手势(拖条本体 / 拖把手 / 拖条上的关键帧)都会把这条片段标成选中,
|
||||
* 点轨道空白处取消。不跟关键帧 / 轨迹点选中做联动,是为了让「点空白处取消选中」始终有效。
|
||||
*/
|
||||
function isClipSelected(kind: "trajectory" | "camera" | "motion", id: string) {
|
||||
function isClipSelected(kind: "trajectory" | "camera" | "motion" | "motionClip", id: string) {
|
||||
return selectedClip === getTimelineClipKey(kind, id);
|
||||
}
|
||||
|
||||
@@ -867,6 +876,11 @@ export function TimelinePanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (owner.kind === "motionClip") {
|
||||
updateMotionClipTime(owner.objectId, owner.clipId, times[0], targetEnd, times[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration));
|
||||
}
|
||||
|
||||
@@ -880,7 +894,7 @@ export function TimelinePanel() {
|
||||
endEdge = duration
|
||||
) {
|
||||
// 动作片段没有「单个关键帧」可拖(只有起点/终点两端,分别由 trimStart / duration 把手负责)
|
||||
if (owner.kind === "motion") return;
|
||||
if (owner.kind === "motion" || owner.kind === "motionClip") return;
|
||||
|
||||
const targetTime = (times[index] ?? 0) + deltaSeconds;
|
||||
|
||||
@@ -920,6 +934,17 @@ export function TimelinePanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (owner.kind === "motionClip") {
|
||||
updateMotionClipTime(
|
||||
owner.objectId,
|
||||
owner.clipId,
|
||||
startEdge + shift,
|
||||
times[times.length - 1] + shift,
|
||||
times[0]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift));
|
||||
}
|
||||
|
||||
@@ -946,6 +971,17 @@ export function TimelinePanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (owner.kind === "motionClip") {
|
||||
updateMotionClipTime(
|
||||
owner.objectId,
|
||||
owner.clipId,
|
||||
startEdge + deltaSeconds,
|
||||
times[1] ?? startEdge,
|
||||
times[0]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (owner.kind !== "trajectory") return;
|
||||
|
||||
setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds);
|
||||
@@ -970,6 +1006,8 @@ export function TimelinePanel() {
|
||||
// 任何一次片段手势(拖条本体 / 拖把手 / 拖关键帧)都先把这段片段标成选中
|
||||
const clipOwnerId = owner.kind === "camera" ? owner.cameraId : owner.objectId;
|
||||
setSelectedClip(getTimelineClipKey(owner.kind, clipOwnerId));
|
||||
// 选中动作片段时同步到 store,供右侧「基础动作」卡片编辑对应的片段
|
||||
if (owner.kind === "motionClip") selectMotionClip(owner.clipId);
|
||||
|
||||
const lane = event.currentTarget.closest(".timeline-track-lane");
|
||||
const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0;
|
||||
@@ -1737,22 +1775,9 @@ export function TimelinePanel() {
|
||||
|
||||
{characterObjects.map((character) => {
|
||||
const trajectoryPoints = character.trajectoryPoints ?? [];
|
||||
const motion = character.characterRig?.motion;
|
||||
const motionLabel = MOTION_LABELS[motion?.kind ?? "none"];
|
||||
const motionDisabled = motion?.enabled === false;
|
||||
const motionTitle = motionDisabled
|
||||
? `动作轨道:${motionLabel}(当前已关闭)`
|
||||
: `动作轨道:${motionLabel}`;
|
||||
// 动作片段的时间区间:动作从该区间的起点开始生效、到终点停止(区间外角色保持静止)。
|
||||
// 没拖过片段时默认覆盖整条时间轴(起点 0、终点 = 时间轴末尾),保持与旧行为一致。
|
||||
const hasMotion = Boolean(motion && motion.kind !== "none");
|
||||
const motionStart = getMotionStartTime(character, duration);
|
||||
const motionEnd = getMotionEndTime(character, duration, motionStart);
|
||||
const motionLeftPercent = duration > 0 ? (motionStart / duration) * 100 : 0;
|
||||
const motionWidthPercent =
|
||||
duration > 0 ? Math.min(100, ((motionEnd - motionStart) / duration) * 100) : 0;
|
||||
// 动作片段条本体 / 两端把手共用的拖拽归属:kind=motion 让拖拽逻辑走角色动作分支
|
||||
const motionOwner: TimelineTimeDragOwner = { kind: "motion", objectId: character.id };
|
||||
// 动作片段序列:同一角色可叠加多段(如先走再跑)。片段之间允许重叠,后者覆盖前者。
|
||||
const clips = character.characterRig?.motionClips ?? [];
|
||||
const motionTitle = `动作轨道:共 ${clips.length} 段动作片段(点击片段选中后在右侧编辑;拖拽条体或两端把手调整时间)`;
|
||||
const trackKey = getTimelineTrackKey("character", character.id);
|
||||
const collapsed = timelineCollapsedTracks.includes(trackKey);
|
||||
const dragProps = getTrackDragProps(trackKey);
|
||||
@@ -1800,7 +1825,8 @@ export function TimelinePanel() {
|
||||
<div className="timeline-track-lane timeline-character-head-lane">
|
||||
{collapsed ? (
|
||||
<span className="timeline-track-summary">
|
||||
{motionLabel} · {trajectoryPoints.length} 个轨迹点
|
||||
{clips.length > 0 ? `${clips.length} 段动作 · ` : ""}
|
||||
{trajectoryPoints.length} 个轨迹点
|
||||
</span>
|
||||
) : trajectoryTimes.length > 0 && duration > 0 ? (
|
||||
<div
|
||||
@@ -1867,67 +1893,114 @@ export function TimelinePanel() {
|
||||
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
{/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步),以一段可拖拽的「时间片段」呈现 */}
|
||||
{/* 动作轨道:角色的基础动作序列,可添加多段(如先走再跑),每段独立可拖拽 / 删除 */}
|
||||
<div className="timeline-subtrack" title={motionTitle}>
|
||||
<div className="timeline-subtrack-label">动作</div>
|
||||
<div className="timeline-subtrack-label timeline-subtrack-label-action">
|
||||
<span>动作</span>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-add-clip-button"
|
||||
aria-label={`${character.name} 添加动作片段`}
|
||||
title="添加动作片段:在当前播放头位置新增一段动作(如先走再跑),形成动作序列"
|
||||
onClick={() => {
|
||||
selectObject(character.id);
|
||||
addMotionClip(character.id, { start: playhead });
|
||||
}}
|
||||
>
|
||||
<Plus aria-hidden="true" size={12} strokeWidth={2.4} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="timeline-track-lane timeline-motion-lane">
|
||||
{hasMotion && duration > 0 ? (
|
||||
<div
|
||||
className={`timeline-trajectory-bar timeline-clip-bar-motion${
|
||||
isClipSelected("motion", character.id) ? " is-selected" : ""
|
||||
}`}
|
||||
style={{ left: `${motionLeftPercent}%`, width: `${motionWidthPercent}%` }}
|
||||
title={`动作片段 ${formatTime(motionStart)} → ${formatTime(motionEnd)}(时长 ${formatTime(
|
||||
motionEnd - motionStart
|
||||
)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与停止时间`}
|
||||
{...getTimeDragProps(motionOwner, "clip", 0, [motionStart, motionEnd], motionStart, motionEnd)}
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{motionLabel}</span>
|
||||
{/* 左端把手 = 修剪动作起点:之前角色保持静止,到这一刻才开始动 */}
|
||||
<span
|
||||
className="timeline-trajectory-trim is-pre"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${character.name} 动作起点`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.max(motionEnd - 0.1, 0)}
|
||||
aria-valuenow={Number(motionStart.toFixed(2))}
|
||||
aria-valuetext={formatTime(motionStart)}
|
||||
title={`拖动修剪动作起点(${formatTime(motionStart)} 之前角色保持静止),或用左右方向键微调`}
|
||||
onKeyDown={(event) =>
|
||||
handleTrimStartKeyDown(event, motionOwner, [motionStart, motionEnd], motionStart)
|
||||
}
|
||||
{...getTimeDragProps(motionOwner, "trimStart", 0, [motionStart, motionEnd], motionStart, motionEnd)}
|
||||
/>
|
||||
{/* 右端把手 = 停止时间:动作播放到这一刻停止,角色回到静止 */}
|
||||
<span
|
||||
className="timeline-trajectory-trim is-post"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${character.name} 动作停止时间`}
|
||||
aria-valuemin={Number(motionStart.toFixed(2))}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={Number(motionEnd.toFixed(2))}
|
||||
aria-valuetext={formatTime(motionEnd)}
|
||||
title="拖动调整动作停止时间(动作播放到这一刻停止),或用左右方向键微调"
|
||||
onKeyDown={(event) =>
|
||||
handleTimeHandleKeyDown(event, motionOwner, [motionStart, motionEnd], motionEnd)
|
||||
}
|
||||
{...getTimeDragProps(
|
||||
motionOwner,
|
||||
"duration",
|
||||
1,
|
||||
[motionStart, motionEnd],
|
||||
motionEnd,
|
||||
motionEnd
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{clips.length === 0 ? (
|
||||
<span className="timeline-motion-chip">暂无动作 · 点「+」添加</span>
|
||||
) : (
|
||||
<span className={`timeline-motion-chip${motionDisabled ? " is-disabled" : ""}`}>
|
||||
{motionLabel}
|
||||
{motionDisabled ? "(已关闭)" : ""}
|
||||
</span>
|
||||
clips.map((clip) => {
|
||||
const clipStart = clip.start;
|
||||
const clipEnd = Number.isFinite(clip.end) ? clip.end : duration;
|
||||
const clipLeftPercent = duration > 0 ? (clipStart / duration) * 100 : 0;
|
||||
const clipWidthPercent =
|
||||
duration > 0 ? Math.min(100, ((clipEnd - clipStart) / duration) * 100) : 0;
|
||||
const clipLabel = MOTION_LABELS[clip.kind];
|
||||
const clipTimes = [clipStart, clipEnd];
|
||||
const clipOwner: TimelineTimeDragOwner = {
|
||||
kind: "motionClip",
|
||||
objectId: character.id,
|
||||
clipId: clip.id,
|
||||
};
|
||||
const isSelected =
|
||||
selectedMotionClipId === clip.id || isClipSelected("motionClip", clip.id);
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`timeline-trajectory-bar timeline-clip-bar-motion${
|
||||
isSelected ? " is-selected" : ""
|
||||
}${clip.enabled === false ? " is-disabled" : ""}`}
|
||||
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
|
||||
title={`动作片段 ${formatTime(clipStart)} → ${formatTime(clipEnd)}(时长 ${formatTime(
|
||||
clipEnd - clipStart
|
||||
)}):${clipLabel} · 拖动条本体整段平移,拖动左右两端把手分别修剪起点与停止时间`}
|
||||
onClick={() => {
|
||||
selectObject(character.id);
|
||||
selectMotionClip(clip.id);
|
||||
}}
|
||||
{...getTimeDragProps(clipOwner, "clip", 0, clipTimes, clipStart, clipEnd)}
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{clipLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-clip-delete"
|
||||
aria-label={`${character.name} 删除动作片段`}
|
||||
title="删除该动作片段"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeMotionClip(character.id, clip.id);
|
||||
}}
|
||||
>
|
||||
<X aria-hidden="true" size={10} strokeWidth={2.6} />
|
||||
</button>
|
||||
{/* 左端把手 = 修剪动作起点:之前角色保持静止,到这一刻才开始动 */}
|
||||
<span
|
||||
className="timeline-trajectory-trim is-pre"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${character.name} 动作起点`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.max(clipEnd - 0.1, 0)}
|
||||
aria-valuenow={Number(clipStart.toFixed(2))}
|
||||
aria-valuetext={formatTime(clipStart)}
|
||||
title={`拖动修剪动作起点(${formatTime(clipStart)} 之前角色保持静止),或用左右方向键微调`}
|
||||
onKeyDown={(event) =>
|
||||
handleTrimStartKeyDown(event, clipOwner, clipTimes, clipStart)
|
||||
}
|
||||
{...getTimeDragProps(clipOwner, "trimStart", 0, clipTimes, clipStart, clipEnd)}
|
||||
/>
|
||||
{/* 右端把手 = 停止时间:动作播放到这一刻停止,角色回到静止 */}
|
||||
<span
|
||||
className="timeline-trajectory-trim is-post"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${character.name} 动作停止时间`}
|
||||
aria-valuemin={Number(clipStart.toFixed(2))}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={Number(clipEnd.toFixed(2))}
|
||||
aria-valuetext={formatTime(clipEnd)}
|
||||
title="拖动调整动作停止时间(动作播放到这一刻停止),或用左右方向键微调"
|
||||
onKeyDown={(event) =>
|
||||
handleTimeHandleKeyDown(event, clipOwner, clipTimes, clipEnd)
|
||||
}
|
||||
{...getTimeDragProps(
|
||||
clipOwner,
|
||||
"duration",
|
||||
1,
|
||||
clipTimes,
|
||||
clipStart,
|
||||
clipEnd
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ it("renders the built-in UE4 mannequin for generated director characters", () =>
|
||||
posePresetId: "stand",
|
||||
controls: {},
|
||||
}}
|
||||
duration={10}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -39,6 +40,7 @@ it("keeps the procedural mannequin fallback for non-UE4 rigs", () => {
|
||||
posePresetId: "stand",
|
||||
controls: {},
|
||||
}}
|
||||
duration={10}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
import type { CharacterRigState } from "../schema/directorProject";
|
||||
import type { CharacterRigState, MotionClip } from "../schema/directorProject";
|
||||
import { PrimitiveMannequin } from "./PrimitiveMannequin";
|
||||
import { UE4MannequinModel } from "./UE4MannequinModel";
|
||||
import { GltfCharacterModel } from "./GltfCharacterModel";
|
||||
import type { CharacterBodyType } from "./mannequin/bodyTypes";
|
||||
import { getActiveMotionClipAtTime, resolveMotionClipEnd } from "./mannequin/motion";
|
||||
|
||||
interface CharacterModelProps {
|
||||
bodyType?: CharacterBodyType;
|
||||
@@ -15,6 +17,12 @@ interface CharacterModelProps {
|
||||
motionStartTime?: number;
|
||||
/** 动作片段终点(秒):缺省视为时间轴末尾 */
|
||||
motionEndTime?: number;
|
||||
/** 时间轴上的动作片段序列(多段动作)。存在时按播放时间取当前片段覆盖上面的单一 motion。 */
|
||||
motionClips?: MotionClip[];
|
||||
/** 时间轴总时长(秒),用于解析到末尾的动作片段。 */
|
||||
duration: number;
|
||||
/** 当 rigState.modelAssetId 指向一个导入的 GLB/GLTF 资产时,其可访问的 url(用于渲染该角色几何)。 */
|
||||
modelAssetUrl?: string;
|
||||
}
|
||||
|
||||
class CharacterModelBoundary extends Component<
|
||||
@@ -37,8 +45,36 @@ class CharacterModelBoundary extends Component<
|
||||
}
|
||||
}
|
||||
|
||||
export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId, motionStartTime, motionEndTime }: CharacterModelProps) {
|
||||
const fallback = <PrimitiveMannequin bodyType={bodyType} color={color} rigState={rigState} motionTime={motionTime} characterId={characterId} motionStartTime={motionStartTime} motionEndTime={motionEndTime} />;
|
||||
export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId, motionStartTime, motionEndTime, modelAssetUrl, motionClips, duration }: CharacterModelProps) {
|
||||
// 动作序列:按当前播放时间取出应生效的片段(后段覆盖前段),用它覆盖单一 motion 字段。
|
||||
// 无片段 / 时间不在任何片段内 → 回退到旧的单一段落(rigState.motion + motionStartTime/EndTime)。
|
||||
const activeClip = getActiveMotionClipAtTime(motionClips, motionTime ?? 0, duration);
|
||||
const resolvedRigState: CharacterRigState | undefined = activeClip && rigState
|
||||
? { ...rigState, motion: activeClip }
|
||||
: rigState;
|
||||
const resolvedStart = activeClip ? activeClip.start : motionStartTime;
|
||||
const resolvedEnd = activeClip ? resolveMotionClipEnd(activeClip, duration) : motionEndTime;
|
||||
|
||||
const fallback = <PrimitiveMannequin bodyType={bodyType} color={color} rigState={resolvedRigState} motionTime={motionTime} characterId={characterId} motionStartTime={resolvedStart} motionEndTime={resolvedEnd} />;
|
||||
|
||||
// 导入的 GLB/GLTF 人形角色:几何来自资产,用通用人形骨骼映射驱动程序化动作。
|
||||
if (rigState?.modelAssetId && modelAssetUrl) {
|
||||
return (
|
||||
<CharacterModelBoundary fallback={fallback}>
|
||||
<GltfCharacterModel
|
||||
url={modelAssetUrl}
|
||||
bodyType={bodyType}
|
||||
color={color}
|
||||
onLabelAnchorYChange={onLabelAnchorYChange}
|
||||
rigState={resolvedRigState}
|
||||
motionTime={motionTime}
|
||||
characterId={characterId}
|
||||
motionStartTime={resolvedStart}
|
||||
motionEndTime={resolvedEnd}
|
||||
/>
|
||||
</CharacterModelBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (rigState?.rigType !== "ue4-mannequin") {
|
||||
return fallback;
|
||||
@@ -50,11 +86,11 @@ export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState
|
||||
bodyType={bodyType}
|
||||
color={color}
|
||||
onLabelAnchorYChange={onLabelAnchorYChange}
|
||||
rigState={rigState}
|
||||
rigState={resolvedRigState}
|
||||
motionTime={motionTime}
|
||||
characterId={characterId}
|
||||
motionStartTime={motionStartTime}
|
||||
motionEndTime={motionEndTime}
|
||||
motionStartTime={resolvedStart}
|
||||
motionEndTime={resolvedEnd}
|
||||
/>
|
||||
</CharacterModelBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useLoader, useThree } from "@react-three/fiber";
|
||||
import { useLayoutEffect, useMemo } from "react";
|
||||
import { Box3, Group, Vector3, type Object3D } from "three";
|
||||
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import type { CharacterRigState } from "../schema/directorProject";
|
||||
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
|
||||
import type { CharacterBodyType } from "./mannequin/bodyTypes";
|
||||
import { applyMotionOverrides } from "./mannequin/motion";
|
||||
import { configureGltfLoader } from "./gltf/gltfLoaderConfig";
|
||||
import {
|
||||
applyHumanoidRestPoseAndRig,
|
||||
captureHumanoidRestPose,
|
||||
classifyHumanoidRig,
|
||||
} from "./gltf/gltfHumanoidRig";
|
||||
|
||||
interface GltfCharacterModelProps {
|
||||
/** 导入的 GLB/GLTF 资产 url(来自 characterRig.modelAssetId 对应的资产)。 */
|
||||
url: string;
|
||||
bodyType?: CharacterBodyType;
|
||||
color?: string;
|
||||
onLabelAnchorYChange?: (anchorY: number) => void;
|
||||
rigState?: CharacterRigState;
|
||||
/** 全局时间(秒)= playbackStore.playhead;驱动基础动作循环。 */
|
||||
motionTime?: number;
|
||||
/** 稳定 id,用于群众相位偏移。 */
|
||||
characterId?: string;
|
||||
/** 动作片段起点(秒)。 */
|
||||
motionStartTime?: number;
|
||||
/** 动作片段终点(秒)。 */
|
||||
motionEndTime?: number;
|
||||
}
|
||||
|
||||
/** 与 SceneRoot 的导入模型归一化保持一致:缩放到该最大尺寸并落地对齐。 */
|
||||
const IMPORTED_MODEL_TARGET_MAX_SIZE = 1.5;
|
||||
|
||||
function computeNormalization(scene: Object3D) {
|
||||
const bounds = new Box3().setFromObject(scene);
|
||||
const size = bounds.getSize(new Vector3());
|
||||
const maxDimension = Math.max(size.x, size.y, size.z, 0.0001);
|
||||
const scale = IMPORTED_MODEL_TARGET_MAX_SIZE / maxDimension;
|
||||
|
||||
return {
|
||||
scale,
|
||||
position: [
|
||||
-bounds.min.x * scale,
|
||||
-bounds.min.y * scale,
|
||||
-bounds.min.z * scale,
|
||||
] as [number, number, number],
|
||||
};
|
||||
}
|
||||
|
||||
export function GltfCharacterModel({
|
||||
url,
|
||||
onLabelAnchorYChange,
|
||||
rigState,
|
||||
motionTime,
|
||||
characterId,
|
||||
motionStartTime,
|
||||
motionEndTime,
|
||||
}: GltfCharacterModelProps) {
|
||||
const gl = useThree((state) => state.gl);
|
||||
const gltf = useLoader(GLTFLoader, url, (loader) => configureGltfLoader(loader, gl)) as unknown as GLTF;
|
||||
// SkeletonUtils.clone 才能正确克隆蒙皮网格(普通 .clone(true) 会让多实例共享同一骨架)。
|
||||
const scene = useMemo(() => cloneSkeleton(gltf.scene) as Group, [gltf.scene]);
|
||||
const restPose = useMemo(() => captureHumanoidRestPose(scene), [scene]);
|
||||
// 运行时从骨骼名识别骨架类型(Mixamo / VRM / 通用人形);非人形则退化为静态渲染。
|
||||
const rigType = useMemo(() => classifyHumanoidRig(scene), [scene]);
|
||||
const rigControls = rigState?.controls ?? {};
|
||||
|
||||
// 基础动作(走/跑)按 motionTime 计算关节角覆盖后合并进 controls(度数域)。
|
||||
// 播放/scrub 时 motionTime 每帧变化 → effect 每帧重摆姿势,即动画本体。
|
||||
const controlsWithMotion = useMemo(
|
||||
() =>
|
||||
applyMotionOverrides(rigControls, {
|
||||
motion: rigState?.motion,
|
||||
time: motionTime ?? 0,
|
||||
characterId: characterId ?? "",
|
||||
startTime: motionStartTime,
|
||||
endTime: motionEndTime,
|
||||
}),
|
||||
[rigControls, rigState?.motion, motionTime, characterId, motionStartTime, motionEndTime]
|
||||
);
|
||||
|
||||
const normalization = useMemo(() => computeNormalization(scene), [scene]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scene.traverse((object) => {
|
||||
object.frustumCulled = false;
|
||||
if ("isSkinnedMesh" in object && object.isSkinnedMesh) {
|
||||
object.castShadow = true;
|
||||
object.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (rigType) {
|
||||
applyHumanoidRestPoseAndRig(scene, {
|
||||
controls: controlsWithMotion,
|
||||
restPose,
|
||||
rigType,
|
||||
});
|
||||
}
|
||||
|
||||
const bounds = new Box3().setFromObject(scene);
|
||||
const size = bounds.getSize(new Vector3());
|
||||
const maxDimension = Math.max(size.x, size.y, size.z, 0.0001);
|
||||
const scaledTop = (bounds.max.y - bounds.min.y) / maxDimension * IMPORTED_MODEL_TARGET_MAX_SIZE;
|
||||
const labelAnchorY = scaledTop + VIEWPORT_OBJECT_LABEL_VERTICAL_GAP;
|
||||
|
||||
if (Number.isFinite(labelAnchorY)) {
|
||||
onLabelAnchorYChange?.(Number(labelAnchorY.toFixed(4)));
|
||||
}
|
||||
}, [controlsWithMotion, onLabelAnchorYChange, restPose, rigType, scene]);
|
||||
|
||||
return (
|
||||
<group name="gltf-character-model" position={normalization.position} scale={normalization.scale}>
|
||||
<primitive object={scene} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Euler, Quaternion, type Object3D } from "three";
|
||||
import type { CharacterRigType } from "../../schema/directorProject";
|
||||
import { collectBoneNames } from "../../loaders/humanoidRig";
|
||||
|
||||
/**
|
||||
* 导入 GLB/GLTF 人形角色的程序化动作驱动。
|
||||
*
|
||||
* 思路与内置 UE4 角色完全一致:
|
||||
* 1. 捕获骨骼的「静止(rest)姿态」四元数;
|
||||
* 2. 复用现有的 walk/run 数学(computeMotionOverrides → controls,单位为度数);
|
||||
* 3. 把 controls 里每个键映射到目标骨架的具体骨骼,并以「局部空间叠加旋转」的方式施加
|
||||
* (quaternion.multiply(quatFromEuler(offset))),与 UE4 的 applyRotationOffset 同语义。
|
||||
*
|
||||
* 不同骨架(Mixamo / VRM / 通用人形)只是「控制键 → 骨骼名」的映射表不同;
|
||||
* 轴向约定(四肢绕局部 X 弯、twist 绕 Y、spread 绕 Z、躯干绕 Y)采用各人形骨架的通用惯例。
|
||||
* 注意:各家导出的骨骼局部坐标系朝向/符号可能不同,若某模型动作方向反了,
|
||||
* 只需在该映射表/轴向符号处微调,不会影响整体架构。
|
||||
*/
|
||||
|
||||
export type HumanoidRigType = Extract<CharacterRigType, "mixamo" | "vrm" | "custom-humanoid">;
|
||||
|
||||
type BoneSemantic = "lhip" | "rhip" | "lknee" | "rknee" | "lsho" | "rsho" | "lelb" | "relb" | "lhand" | "rhand" | "torso" | "body";
|
||||
type Axis = "x" | "y" | "z";
|
||||
|
||||
/** 各人形骨架「语义骨骼 → 归一化骨骼名」。归一化规则见 humanoidRig.collectBoneNames。 */
|
||||
const RIG_BONES: Record<HumanoidRigType, Record<BoneSemantic, string>> = {
|
||||
// Mixamo(可能带 mixamorig: 前缀)与通用人形用同一套命名核心。
|
||||
mixamo: {
|
||||
lhip: "leftupleg",
|
||||
rhip: "rightupleg",
|
||||
lknee: "leftleg",
|
||||
rknee: "rightleg",
|
||||
lsho: "leftarm",
|
||||
rsho: "rightarm",
|
||||
lelb: "leftforearm",
|
||||
relb: "rightforearm",
|
||||
lhand: "lefthand",
|
||||
rhand: "righthand",
|
||||
torso: "spine",
|
||||
body: "hips",
|
||||
},
|
||||
"custom-humanoid": {
|
||||
lhip: "leftupleg",
|
||||
rhip: "rightupleg",
|
||||
lknee: "leftleg",
|
||||
rknee: "rightleg",
|
||||
lsho: "leftarm",
|
||||
rsho: "rightarm",
|
||||
lelb: "leftforearm",
|
||||
relb: "rightforearm",
|
||||
lhand: "lefthand",
|
||||
rhand: "righthand",
|
||||
torso: "spine",
|
||||
body: "hips",
|
||||
},
|
||||
// VRM:J_Bip_L_* / J_Bip_R_* / J_Bip_C_*(归一化后带 jbip_ 前缀)。
|
||||
vrm: {
|
||||
lhip: "jbip_lupperleg",
|
||||
rhip: "jbip_rupperleg",
|
||||
lknee: "jbip_llowerleg",
|
||||
rknee: "jbip_rlowerleg",
|
||||
lsho: "jbip_lupperarm",
|
||||
rsho: "jbip_rupperarm",
|
||||
lelb: "jbip_llowerarm",
|
||||
relb: "jbip_rlowerarm",
|
||||
lhand: "jbip_lhand",
|
||||
rhand: "jbip_rhand",
|
||||
torso: "jbip_cspine",
|
||||
body: "jbip_chips",
|
||||
},
|
||||
};
|
||||
|
||||
/** 程序化动作输出键 → 语义骨骼 + 施加轴向(x=pitch / y=twist|yaw / z=spread)。 */
|
||||
const CONTROL_SEMANTIC_AXIS: Record<string, { s: BoneSemantic; a: Axis }> = {
|
||||
"leftHip.pitch": { s: "lhip", a: "x" },
|
||||
"rightHip.pitch": { s: "rhip", a: "x" },
|
||||
"leftKnee.bend": { s: "lknee", a: "x" },
|
||||
"rightKnee.bend": { s: "rknee", a: "x" },
|
||||
"leftShoulder.pitch": { s: "lsho", a: "x" },
|
||||
"rightShoulder.pitch": { s: "rsho", a: "x" },
|
||||
"leftShoulder.twist": { s: "lsho", a: "y" },
|
||||
"rightShoulder.twist": { s: "rsho", a: "y" },
|
||||
"leftShoulder.spread": { s: "lsho", a: "z" },
|
||||
"rightShoulder.spread": { s: "rsho", a: "z" },
|
||||
"leftElbow.bend": { s: "lelb", a: "x" },
|
||||
"rightElbow.bend": { s: "relb", a: "x" },
|
||||
"leftHand.pitch": { s: "lhand", a: "x" },
|
||||
"rightHand.pitch": { s: "rhand", a: "x" },
|
||||
"torso.yaw": { s: "torso", a: "y" },
|
||||
"body.pitch": { s: "body", a: "x" },
|
||||
};
|
||||
|
||||
const AXIS_INDEX: Record<Axis, 0 | 1 | 2> = { x: 0, y: 1, z: 2 };
|
||||
|
||||
function isBone(object: Object3D): object is Object3D & { isBone: true } {
|
||||
return "isBone" in object && object.isBone === true;
|
||||
}
|
||||
|
||||
function applyRotationOffset(object: Object3D, rotation: [number, number, number]) {
|
||||
object.quaternion.multiply(new Quaternion().setFromEuler(new Euler(rotation[0], rotation[1], rotation[2])));
|
||||
}
|
||||
|
||||
export interface HumanoidRestBoneTransform {
|
||||
position: [number, number, number];
|
||||
quaternion: [number, number, number, number];
|
||||
scale: [number, number, number];
|
||||
}
|
||||
|
||||
export type HumanoidRestPose = Record<string, HumanoidRestBoneTransform>;
|
||||
|
||||
/** 捕获骨骼静止姿态(位置/旋转/缩放),之后每帧先复位再叠加动作偏移。 */
|
||||
export function captureHumanoidRestPose(scene: Object3D): HumanoidRestPose {
|
||||
const restPose: HumanoidRestPose = {};
|
||||
|
||||
scene.traverse((object) => {
|
||||
if (!isBone(object)) return;
|
||||
|
||||
restPose[object.name] = {
|
||||
position: [object.position.x, object.position.y, object.position.z],
|
||||
quaternion: [object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w],
|
||||
scale: [object.scale.x, object.scale.y, object.scale.z],
|
||||
};
|
||||
});
|
||||
|
||||
return restPose;
|
||||
}
|
||||
|
||||
/** 从场景骨骼名建立「归一化名 → 实际骨骼名」映射,用于把语义骨骼定位到具体节点。 */
|
||||
function buildNormalizedBoneMap(scene: Object3D): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
|
||||
scene.traverse((object) => {
|
||||
if (!isBone(object)) return;
|
||||
const normalized = collectBoneNames({ traverse: (cb) => cb(object) })[0];
|
||||
if (!map.has(normalized)) map.set(normalized, object.name);
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 把 controls(度数域)换算成「实际骨骼名 → 局部欧拉偏移(弧度)」。 */
|
||||
function getHumanoidPoseBoneRotations(
|
||||
controls: Record<string, number>,
|
||||
rigType: HumanoidRigType,
|
||||
normalizedToActual: Map<string, string>
|
||||
): Record<string, [number, number, number]> {
|
||||
const boneOffset: Record<string, [number, number, number]> = {};
|
||||
|
||||
for (const [key, degrees] of Object.entries(controls)) {
|
||||
const mapping = CONTROL_SEMANTIC_AXIS[key];
|
||||
if (!mapping || typeof degrees !== "number" || !Number.isFinite(degrees)) continue;
|
||||
|
||||
const normalizedBone = RIG_BONES[rigType][mapping.s];
|
||||
const actualBone = normalizedToActual.get(normalizedBone);
|
||||
if (!actualBone) continue;
|
||||
|
||||
const radians = (degrees * Math.PI) / 180;
|
||||
const entry = (boneOffset[actualBone] ??= [0, 0, 0]);
|
||||
entry[AXIS_INDEX[mapping.a]] += radians;
|
||||
}
|
||||
|
||||
return boneOffset;
|
||||
}
|
||||
|
||||
export interface ApplyHumanoidRestPoseAndRigOptions {
|
||||
controls: Record<string, number>;
|
||||
restPose: HumanoidRestPose;
|
||||
rigType: HumanoidRigType;
|
||||
}
|
||||
|
||||
/** 复位骨骼到静止姿态,再按 controls 施加程序化动作偏移。 */
|
||||
export function applyHumanoidRestPoseAndRig(scene: Object3D, { controls, restPose, rigType }: ApplyHumanoidRestPoseAndRigOptions) {
|
||||
const normalizedToActual = buildNormalizedBoneMap(scene);
|
||||
const offsets = getHumanoidPoseBoneRotations(controls, rigType, normalizedToActual);
|
||||
|
||||
scene.traverse((object) => {
|
||||
if (!isBone(object)) return;
|
||||
|
||||
const rest = restPose[object.name];
|
||||
if (rest) {
|
||||
object.position.set(rest.position[0], rest.position[1], rest.position[2]);
|
||||
object.quaternion.set(rest.quaternion[0], rest.quaternion[1], rest.quaternion[2], rest.quaternion[3]);
|
||||
object.scale.set(rest.scale[0], rest.scale[1], rest.scale[2]);
|
||||
}
|
||||
|
||||
const offset = offsets[object.name];
|
||||
if (offset) applyRotationOffset(object, offset);
|
||||
});
|
||||
}
|
||||
|
||||
/** 从已加载场景识别人形骨架类型;非人形返回 null(此时退化为静态渲染)。 */
|
||||
export function classifyHumanoidRig(scene: Object3D): HumanoidRigType | null {
|
||||
const set = new Set(collectBoneNames(scene));
|
||||
|
||||
if (set.has("jbip_chips") || set.has("jbip_chead")) return "vrm";
|
||||
if (set.has("hips") && set.has("spine") && set.has("head")) return "mixamo";
|
||||
if (set.has("leftupleg") && set.has("leftleg") && set.has("leftarm") && set.has("leftforearm")) return "custom-humanoid";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
|
||||
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
||||
import type { WebGLRenderer } from "three";
|
||||
|
||||
/**
|
||||
* 为 GLTFLoader 实例装配解码器,统一在 GltfModel 与 GltfCharacterModel 之间复用:
|
||||
* - DRACO:压缩几何(无渲染器依赖,仅设路径)。
|
||||
* - Meshopt:另一种常见几何压缩(模块式解码器,直接挂载)。
|
||||
* - KTX2:GPU 纹理压缩,需要 WebGLRenderer 做能力探测。
|
||||
* 解码器文件随 public/ 一并发布(draco/、basis/),离线可用。
|
||||
* 用 loader 上已有字段做幂等判断,避免重复构造解码器实例。
|
||||
*/
|
||||
export function configureGltfLoader(loader: GLTFLoader, gl: WebGLRenderer) {
|
||||
if (!loader.dracoLoader) {
|
||||
const draco = new DRACOLoader();
|
||||
draco.setDecoderPath(`${import.meta.env.BASE_URL}draco/`);
|
||||
loader.setDRACOLoader(draco);
|
||||
}
|
||||
|
||||
if (!loader.meshoptDecoder) {
|
||||
loader.setMeshoptDecoder(MeshoptDecoder);
|
||||
}
|
||||
|
||||
if (!loader.ktx2Loader && gl) {
|
||||
const ktx2 = new KTX2Loader();
|
||||
ktx2.setTranscoderPath(`${import.meta.env.BASE_URL}basis/`);
|
||||
ktx2.detectSupport(gl);
|
||||
loader.setKTX2Loader(ktx2);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMotionOverrides,
|
||||
computeMotionOverrides,
|
||||
hashIdToPhase,
|
||||
MOTION_DRIVEN_CONTROL_KEYS,
|
||||
RUN_FREQUENCY_MULTIPLIER,
|
||||
} from "./motion";
|
||||
import type { CharacterMotionState } from "../../schema/directorProject";
|
||||
import { getActiveMotionClipAtTime, resolveMotionClipEnd } from "./motion";
|
||||
import type { MotionClip } from "../../schema/directorProject";
|
||||
|
||||
const WALK: CharacterMotionState = { kind: "walk", speed: 1, amplitude: 1, enabled: true };
|
||||
const RUN: CharacterMotionState = { kind: "run", speed: 1, amplitude: 1, enabled: true };
|
||||
function clip(partial: Partial<MotionClip> & Pick<MotionClip, "id" | "start" | "end">): MotionClip {
|
||||
return {
|
||||
kind: "walk",
|
||||
speed: 1,
|
||||
amplitude: 1,
|
||||
enabled: true,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeMotionOverrides", () => {
|
||||
it("returns empty override when motion is null/undefined", () => {
|
||||
expect(computeMotionOverrides({ motion: null, time: 1, characterId: "x" })).toEqual({});
|
||||
expect(computeMotionOverrides({ motion: undefined, time: 1, characterId: "x" })).toEqual({});
|
||||
describe("getActiveMotionClipAtTime", () => {
|
||||
it("returns null when there are no clips", () => {
|
||||
expect(getActiveMotionClipAtTime(undefined, 1, 10)).toBeNull();
|
||||
expect(getActiveMotionClipAtTime([], 1, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty override when kind is none", () => {
|
||||
expect(computeMotionOverrides({ motion: { ...WALK, kind: "none" }, time: 1, characterId: "x" })).toEqual({});
|
||||
it("returns the clip covering the current time", () => {
|
||||
const a = clip({ id: "a", start: 0, end: 2 });
|
||||
const b = clip({ id: "b", kind: "run", start: 2, end: 5 });
|
||||
expect(getActiveMotionClipAtTime([a, b], 1, 5)?.id).toBe("a");
|
||||
expect(getActiveMotionClipAtTime([a, b], 3, 5)?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("returns empty override when enabled=false", () => {
|
||||
expect(computeMotionOverrides({ motion: { ...WALK, enabled: false }, time: 1, characterId: "x" })).toEqual({});
|
||||
it("later clips win on overlap (last covering segment wins)", () => {
|
||||
const a = clip({ id: "a", start: 0, end: 5 });
|
||||
const b = clip({ id: "b", start: 2, end: 6 });
|
||||
expect(getActiveMotionClipAtTime([a, b], 3, 6)?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("returns empty override for idle (MVP placeholder)", () => {
|
||||
expect(
|
||||
computeMotionOverrides({ motion: { ...WALK, kind: "idle" }, time: 1, characterId: "x" })
|
||||
).toEqual({});
|
||||
it("treats an open-ended clip (Infinity) as lasting to the timeline end", () => {
|
||||
const a = clip({ id: "a", start: 0, end: Number.POSITIVE_INFINITY });
|
||||
expect(getActiveMotionClipAtTime([a], 999, 10)?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("only drives the character inside the [startTime, endTime] segment", () => {
|
||||
const segment = { motion: WALK, time: 1, characterId: "x", startTime: 0.5, endTime: 2.5 };
|
||||
|
||||
// 区间内:正常驱动
|
||||
expect(computeMotionOverrides(segment)).not.toEqual({});
|
||||
// 区间起点之前:保持静止
|
||||
expect(
|
||||
computeMotionOverrides({ motion: WALK, time: 0.1, characterId: "x", startTime: 0.5, endTime: 2.5 })
|
||||
).toEqual({});
|
||||
// 区间终点之后:保持静止
|
||||
expect(
|
||||
computeMotionOverrides({ motion: WALK, time: 3, characterId: "x", startTime: 0.5, endTime: 2.5 })
|
||||
).toEqual({});
|
||||
it("returns null for disabled / none clips (character stays static)", () => {
|
||||
const disabled = clip({ id: "a", start: 0, end: 5, enabled: false });
|
||||
const none = clip({ id: "b", start: 0, end: 5, kind: "none" });
|
||||
expect(getActiveMotionClipAtTime([disabled], 1, 5)).toBeNull();
|
||||
expect(getActiveMotionClipAtTime([none], 1, 5)).toBeNull();
|
||||
});
|
||||
|
||||
/** 求使左肢相位 θL = target 的 time(speed=1,走路频率)。 */
|
||||
function timeForLeftPhase(target: number, characterId: string): number {
|
||||
const phase = hashIdToPhase(characterId);
|
||||
return (target - phase) / (Math.PI * 2);
|
||||
}
|
||||
|
||||
/** 求使跑步左肢相位 θL = target 的 time(speed=1;跑步基础频率 ×1.5)。 */
|
||||
function timeForRunLeftPhase(target: number, characterId: string): number {
|
||||
const phase = hashIdToPhase(characterId);
|
||||
return (target - phase) / (RUN_FREQUENCY_MULTIPLIER * Math.PI * 2);
|
||||
}
|
||||
|
||||
it("walk: 腿摆到最前(脚跟着地)时膝盖几乎伸直 —— 自然步态关键特征", () => {
|
||||
const t = timeForLeftPhase(Math.PI / 2, "x");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" });
|
||||
|
||||
// 左髋前摆峰值 25°
|
||||
expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25);
|
||||
// 右髋后摆 -12°(不对称摆幅)
|
||||
expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12);
|
||||
// 左膝着地瞬间伸直
|
||||
expect(o["leftKnee.bend"]).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("walk: 摆动中期膝峰 45°,对侧腿同时伸直", () => {
|
||||
const t = timeForLeftPhase(0, "x");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" });
|
||||
|
||||
expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(45);
|
||||
expect(o["rightKnee.bend"]).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("walk: 肘部微屈并随摆臂开合(前摆收紧、后摆打开)", () => {
|
||||
const t = timeForLeftPhase(Math.PI / 2, "x");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" });
|
||||
|
||||
// θL=π/2:左臂后摆(肘打开 6°),右臂前摆(肘收紧 18°);中心 12° ± 6°
|
||||
expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(6);
|
||||
expect((o["rightElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(18);
|
||||
expect(o).toHaveProperty("torso.yaw");
|
||||
});
|
||||
|
||||
it("walk: 臂与对侧腿同相(正值=前摆;左臂随右腿,不顺拐)", () => {
|
||||
const t = timeForLeftPhase(Math.PI / 2, "x");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" });
|
||||
|
||||
// 左腿前摆(+25)→ 左臂后摆(-16)、右臂前摆(+24),对侧摆动
|
||||
expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-16);
|
||||
expect((o["rightShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(24);
|
||||
});
|
||||
|
||||
it("run: 前倾 -11°、肩对称摆 ±36°+微内旋 8°+贴身外展、屈肘明显", () => {
|
||||
const t = timeForRunLeftPhase(0, "x");
|
||||
const o = computeMotionOverrides({ motion: RUN, time: t, characterId: "x" });
|
||||
|
||||
// 摆动中期(θarm 中点)肘角 55°(区间中点)
|
||||
expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(55);
|
||||
// 肩部:对称摆幅 ±36°,中心 0(此刻两臂都过中点)
|
||||
expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(0);
|
||||
// 上臂微内旋 8°
|
||||
expect((o["leftShoulder.twist"] as number) * 180 / Math.PI).toBeCloseTo(8);
|
||||
expect((o["rightShoulder.twist"] as number) * 180 / Math.PI).toBeCloseTo(8);
|
||||
// 双臂贴身:微外展 8°(左右符号镜像)
|
||||
expect((o["leftShoulder.spread"] as number) * 180 / Math.PI).toBeCloseTo(-8);
|
||||
expect((o["rightShoulder.spread"] as number) * 180 / Math.PI).toBeCloseTo(8);
|
||||
// 身体前倾为负值方向,中心 -14° ± 2° 起伏
|
||||
const bodyPitchDeg = (o["body.pitch"] as number) * 180 / Math.PI;
|
||||
expect(bodyPitchDeg).toBeLessThan(-12);
|
||||
expect(bodyPitchDeg).toBeGreaterThan(-16);
|
||||
// 腕部微屈(握拳近似)
|
||||
expect((o["leftHand.pitch"] as number) * 180 / Math.PI).toBeCloseTo(12);
|
||||
// 摆动中期膝峰 90°(跑步小腿折叠)
|
||||
expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it("run: 肘部在 48°~62° 区间开合(前摆收紧、后摆稍开),肩前 +36 后 -36", () => {
|
||||
// θL=π/2:左腿前摆 → 左臂后摆(肘 48),右臂前摆(肘收紧 62)
|
||||
const t = timeForRunLeftPhase(Math.PI / 2, "x");
|
||||
const o = computeMotionOverrides({ motion: RUN, time: t, characterId: "x" });
|
||||
|
||||
expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(48);
|
||||
expect((o["rightElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(62);
|
||||
// 肩摆(正=前摆):右臂随左腿对侧前摆到 +36,左臂后摆到 -36 —— 对侧,不顺拐
|
||||
expect((o["rightShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(36);
|
||||
expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-36);
|
||||
// 腿摆幅加大:左髋前摆峰值 52°,右髋同时后摆 -26°
|
||||
expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(52);
|
||||
expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-26);
|
||||
// 跑姿前摆腿保持屈膝:腿到最前时膝 55°(不是走路的伸直 0°)
|
||||
expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(55);
|
||||
});
|
||||
|
||||
it("walk respects amplitude multiplier", () => {
|
||||
const strong: CharacterMotionState = { ...WALK, amplitude: 1.5 };
|
||||
const mild = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" });
|
||||
const big = computeMotionOverrides({ motion: strong, time: 0.25, characterId: "x" });
|
||||
// amplitude 是纯乘子
|
||||
expect((big["leftHip.pitch"] as number) / (mild["leftHip.pitch"] as number)).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it("walk respects speed multiplier", () => {
|
||||
const fast: CharacterMotionState = { ...WALK, speed: 2 };
|
||||
const slow = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" });
|
||||
const quick = computeMotionOverrides({ motion: fast, time: 0.125, characterId: "x" });
|
||||
// speed=2, t=0.125 等价于 speed=1, t=0.25
|
||||
expect(quick["leftHip.pitch"] as number).toBeCloseTo(slow["leftHip.pitch"] as number);
|
||||
});
|
||||
|
||||
it("walk covers expected joint keys", () => {
|
||||
const overrides = computeMotionOverrides({ motion: WALK, time: 0.5, characterId: "char_42" });
|
||||
const keys = new Set(Object.keys(overrides));
|
||||
for (const expected of [
|
||||
"leftHip.pitch",
|
||||
"rightHip.pitch",
|
||||
"leftKnee.bend",
|
||||
"rightKnee.bend",
|
||||
"leftShoulder.pitch",
|
||||
"rightShoulder.pitch",
|
||||
"leftElbow.bend",
|
||||
"rightElbow.bend",
|
||||
"torso.yaw",
|
||||
]) {
|
||||
expect(keys).toContain(expected);
|
||||
}
|
||||
// 所有覆盖的键必须在 MOTION_DRIVEN_CONTROL_KEYS 中
|
||||
for (const k of keys) {
|
||||
expect(MOTION_DRIVEN_CONTROL_KEYS.has(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("left and right hips are 180° out of phase (asymmetric swing: forward 25° / backward 12°)", () => {
|
||||
// θL=π/2:左髋前摆峰值 25°,右髋(θR=θL+π)同时到达后摆峰值 -12°
|
||||
const t = timeForLeftPhase(Math.PI / 2, "char_42");
|
||||
const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "char_42" });
|
||||
expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25);
|
||||
expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12);
|
||||
|
||||
// 半周期后左右互换(真正的 180° 反相验证)
|
||||
const tHalf = timeForLeftPhase(Math.PI / 2 + Math.PI, "char_42");
|
||||
const oHalf = computeMotionOverrides({ motion: WALK, time: tHalf, characterId: "char_42" });
|
||||
expect((oHalf["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12);
|
||||
expect((oHalf["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25);
|
||||
});
|
||||
|
||||
it("different character ids produce different phase offsets", () => {
|
||||
const a = computeMotionOverrides({ motion: WALK, time: 0.3, characterId: "char_a" });
|
||||
const b = computeMotionOverrides({ motion: WALK, time: 0.3, characterId: "char_b" });
|
||||
// 群体里不同个体不应齐刷刷
|
||||
expect(a["leftHip.pitch"]).not.toBeCloseTo(b["leftHip.pitch"] as number);
|
||||
});
|
||||
|
||||
it("returns empty override for non-finite time", () => {
|
||||
expect(computeMotionOverrides({ motion: WALK, time: Number.NaN, characterId: "x" })).toEqual({});
|
||||
expect(computeMotionOverrides({ motion: WALK, time: Number.POSITIVE_INFINITY, characterId: "x" })).toEqual({});
|
||||
it("returns null outside any clip", () => {
|
||||
const a = clip({ id: "a", start: 1, end: 4 });
|
||||
expect(getActiveMotionClipAtTime([a], 0, 5)).toBeNull();
|
||||
expect(getActiveMotionClipAtTime([a], 5, 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMotionOverrides", () => {
|
||||
it("returns the same controls reference when nothing is overridden", () => {
|
||||
const controls = { "head.yaw": 5 };
|
||||
expect(
|
||||
applyMotionOverrides(controls, { motion: null, time: 1, characterId: "x" })
|
||||
).toBe(controls);
|
||||
expect(
|
||||
applyMotionOverrides(controls, { motion: { ...WALK, kind: "none" }, time: 1, characterId: "x" })
|
||||
).toBe(controls);
|
||||
describe("resolveMotionClipEnd", () => {
|
||||
it("keeps a finite end within bounds", () => {
|
||||
const a = clip({ id: "a", start: 1, end: 4 });
|
||||
expect(resolveMotionClipEnd(a, 10)).toBe(4);
|
||||
});
|
||||
|
||||
it("分层合并:动作角度 + controls 偏移(度数域)", () => {
|
||||
const controls = { "head.yaw": 5, "leftHip.pitch": 3 };
|
||||
const merged = applyMotionOverrides(controls, { motion: WALK, time: 0.25, characterId: "x" });
|
||||
const expected = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" });
|
||||
|
||||
expect(merged).not.toBe(controls);
|
||||
expect(merged["head.yaw"]).toBe(5); // 动作未驱动的关节原样保留
|
||||
// 动作驱动的关节 = 动作角度 + controls 偏移
|
||||
expect(merged["leftHip.pitch"]).toBeCloseTo(3 + ((expected["leftHip.pitch"] as number) * 180) / Math.PI);
|
||||
});
|
||||
|
||||
it("偏移微调示例:髋 +10° 偏移叠加在动作循环之上", () => {
|
||||
const base = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" });
|
||||
const offsetDeg = 10;
|
||||
const merged = applyMotionOverrides(
|
||||
{ "leftHip.pitch": offsetDeg },
|
||||
{ motion: WALK, time: 0.25, characterId: "x" }
|
||||
);
|
||||
|
||||
expect(merged["leftHip.pitch"]).toBeCloseTo(offsetDeg + ((base["leftHip.pitch"] as number) * 180) / Math.PI);
|
||||
it("resolves an open-ended end to the timeline duration", () => {
|
||||
const a = clip({ id: "a", start: 1, end: Number.POSITIVE_INFINITY });
|
||||
expect(resolveMotionClipEnd(a, 10)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashIdToPhase", () => {
|
||||
it("produces a value in [0, 2π)", () => {
|
||||
for (const id of ["a", "char_1", "long-character-id-123", "中文-id-也-ok"]) {
|
||||
const p = hashIdToPhase(id);
|
||||
expect(p).toBeGreaterThanOrEqual(0);
|
||||
expect(p).toBeLessThan(Math.PI * 2);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
expect(hashIdToPhase("foo")).toBe(hashIdToPhase("foo"));
|
||||
});
|
||||
|
||||
it("different ids yield different phases in general", () => {
|
||||
const phases = new Set(["a", "b", "c", "d", "e", "f"].map(hashIdToPhase));
|
||||
expect(phases.size).toBeGreaterThan(4);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CharacterMotionState, MotionKind } from "../../schema/directorProject";
|
||||
import type { CharacterMotionState, MotionClip, MotionKind } from "../../schema/directorProject";
|
||||
import { MOTION_CLIP_MIN_DURATION } from "../../schema/trajectoryMotion";
|
||||
|
||||
/**
|
||||
* 基础动作关节驱动
|
||||
@@ -43,6 +44,44 @@ function normalizeMotion(motion: CharacterMotionState | null | undefined): Chara
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按播放时间取出应生效的动作片段(动作序列核心)。
|
||||
* - 返回覆盖 `time` 的「最后一段」片段(数组靠后者覆盖靠前者),支持片段重叠时后者胜。
|
||||
* - 片段 `end` 为 Infinity 时按 `duration` 收尾,保证「到时间轴末尾」语义。
|
||||
* - kind 为 none 或 disabled 的片段视为「静止」,返回 null(让角色保持静止姿态)。
|
||||
* - 无片段 / 时间不在任何片段内 → 返回 null。
|
||||
*/
|
||||
export function getActiveMotionClipAtTime(
|
||||
clips: MotionClip[] | null | undefined,
|
||||
time: number,
|
||||
duration: number
|
||||
): MotionClip | null {
|
||||
if (!clips || clips.length === 0 || !Number.isFinite(time)) return null;
|
||||
const dur = Number.isFinite(duration) ? duration : Number.POSITIVE_INFINITY;
|
||||
// 播放头理论上钳制在 [0, duration],这里再保险一次,避免略超末尾时漏掉「到末尾」的开放片段
|
||||
const safeTime = Number.isFinite(dur) ? Math.min(Math.max(time, 0), dur) : Math.max(time, 0);
|
||||
let active: MotionClip | null = null;
|
||||
|
||||
for (const clip of clips) {
|
||||
const start = Math.max(Number.isFinite(clip.start) ? clip.start : 0, 0);
|
||||
const end = Number.isFinite(clip.end) ? Math.min(clip.end, dur) : dur;
|
||||
if (safeTime >= start && safeTime <= end) {
|
||||
active = clip; // 后段覆盖前段
|
||||
}
|
||||
}
|
||||
|
||||
if (!active) return null;
|
||||
if (active.kind === "none" || active.enabled === false) return null;
|
||||
return active;
|
||||
}
|
||||
|
||||
/** 把片段终点归一化:非有限值(Infinity)按时间轴末尾处理,否则夹在 [start, duration] 内。 */
|
||||
export function resolveMotionClipEnd(clip: MotionClip, duration: number): number {
|
||||
const dur = Number.isFinite(duration) ? duration : Number.POSITIVE_INFINITY;
|
||||
if (!Number.isFinite(clip.end)) return dur;
|
||||
return Math.min(Math.max(clip.end, clip.start + MOTION_CLIP_MIN_DURATION), dur);
|
||||
}
|
||||
|
||||
/** 把任意 string hash 成 [0, 2π) 的浮点。FNV-1a 足够稳定。 */
|
||||
export function hashIdToPhase(id: string): number {
|
||||
let hash = 2166136261;
|
||||
|
||||
@@ -70,6 +70,24 @@ export interface SceneSettings {
|
||||
/** 基础动作类型:none=不应用循环,idle/walk/run 由 ProceduralMannequin 按 time 驱动。 */
|
||||
export type MotionKind = "none" | "idle" | "walk" | "run";
|
||||
|
||||
/**
|
||||
* 单个动作片段:角色在时间轴上某段区间内播放的基础动作(待机 / 行走 / 跑步)。
|
||||
* 同一角色可叠加多个片段(动作序列,如先走再跑)。片段之间允许重叠,
|
||||
* 播放时「后定义的片段」覆盖先定义的片段(最后覆盖者胜)。
|
||||
* `end` 为非有限值(Infinity)时表示一直播放到时间轴末尾。
|
||||
*/
|
||||
export interface MotionClip {
|
||||
id: string;
|
||||
kind: MotionKind;
|
||||
speed: number;
|
||||
amplitude: number;
|
||||
enabled: boolean;
|
||||
/** 片段起点(秒):角色在此之前保持静止。 */
|
||||
start: number;
|
||||
/** 片段终点(秒):非有限值表示到时间轴末尾。 */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** 单个角色的「动作」配置。当前 MVP 只接通 walk,其它类型字段保留以便后续扩。 */
|
||||
export interface CharacterMotionState {
|
||||
kind: MotionKind;
|
||||
@@ -87,6 +105,10 @@ export interface CharacterRigState {
|
||||
controls: Record<string, number>;
|
||||
/** 缺省视为 { kind: "none", speed: 1, amplitude: 1, enabled: true };旧工程无需迁移。 */
|
||||
motion?: CharacterMotionState;
|
||||
/** 时间轴上的动作片段序列(多段不重叠动作)。缺省为空 → 回退到单一 `motion` 字段。 */
|
||||
motionClips?: MotionClip[];
|
||||
/** 当该角色几何来自「导入的 GLB/GLTF 资产」时,指向该资产 id;渲染用其 url 加载人形模型并驱动动作。 */
|
||||
modelAssetId?: string;
|
||||
}
|
||||
|
||||
export interface DirectorAssetRef {
|
||||
@@ -98,6 +120,12 @@ export interface DirectorAssetRef {
|
||||
url: string;
|
||||
assetSource?: DirectorAssetSource;
|
||||
projectionMode?: PanoramaProjectionMode;
|
||||
/**
|
||||
* GLB/GLTF 等二进制模型的 IndexedDB 存储 id。
|
||||
* 存在时 localStorage 只保存轻量元数据(url 置空),运行时再从 IndexedDB 还原 objectURL,
|
||||
* 避免大文件 base64 撑爆 localStorage。
|
||||
*/
|
||||
binaryId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createInitialDirectorState, useDirectorStore } from "./directorStore";
|
||||
|
||||
function getCharacter(id: string) {
|
||||
return useDirectorStore.getState().project.objects.find((item) => item.id === id);
|
||||
}
|
||||
function getClips(id: string) {
|
||||
return getCharacter(id)?.characterRig?.motionClips ?? [];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDirectorStore.setState({
|
||||
...useDirectorStore.getState(),
|
||||
...createInitialDirectorState(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("动作片段拖动不重叠(可交换位置)", () => {
|
||||
function setupTwoClips() {
|
||||
const s = useDirectorStore.getState();
|
||||
// 给足够长的时间轴,避免片段被默认较短的时长截断,干扰重叠推挤逻辑的验证
|
||||
s.setTimelineDuration(30);
|
||||
// 两个有限区间,不会触发 addMotionClip 自带的「到末尾」截断逻辑
|
||||
s.addMotionClip("char_default_a", { kind: "walk", start: 0, end: 5 });
|
||||
s.addMotionClip("char_default_a", { kind: "run", start: 5, end: 10 });
|
||||
const clips = getClips("char_default_a");
|
||||
return { a: clips[0].id, b: clips[1].id };
|
||||
}
|
||||
|
||||
it("整段右移穿过另一段:两者不重叠,被压片段被推到左侧", () => {
|
||||
const { a, b } = setupTwoClips();
|
||||
useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 6, 11, 0);
|
||||
const clipA = getClips("char_default_a").find((c) => c.id === a)!;
|
||||
const clipB = getClips("char_default_a").find((c) => c.id === b)!;
|
||||
expect(clipA.start).toBe(6);
|
||||
expect(clipB.end).toBeLessThanOrEqual(clipA.start);
|
||||
expect(clipB.start).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("整段左移穿过另一段:被压片段被推到右侧", () => {
|
||||
const { a, b } = setupTwoClips();
|
||||
useDirectorStore.getState().updateMotionClipTime("char_default_a", b, 0, 4, 5);
|
||||
const clipA = getClips("char_default_a").find((c) => c.id === a)!;
|
||||
const clipB = getClips("char_default_a").find((c) => c.id === b)!;
|
||||
expect(clipB.start).toBe(0);
|
||||
expect(clipA.start).toBeGreaterThanOrEqual(clipB.end);
|
||||
});
|
||||
|
||||
it("右端把手扩张进入另一段:被压片段被挤到右侧", () => {
|
||||
const { a, b } = setupTwoClips();
|
||||
// 拖动 A 的右端把手从 5 扩张到 8(进入 B[5,10]),起点不变 → 推 B 到右侧
|
||||
useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 0, 8, 0);
|
||||
const clipA = getClips("char_default_a").find((c) => c.id === a)!;
|
||||
const clipB = getClips("char_default_a").find((c) => c.id === b)!;
|
||||
expect(clipA.end).toBe(8);
|
||||
expect(clipB.start).toBeGreaterThanOrEqual(clipA.end);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
DirectorAssetRef,
|
||||
DirectorAssetSource,
|
||||
CharacterBodyType,
|
||||
CharacterRigState,
|
||||
DirectorAssetKind,
|
||||
DirectorCameraCapture,
|
||||
DirectorCameraShot,
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
DirectorTrajectoryPointRef,
|
||||
DirectorTransform,
|
||||
GeometryPrimitiveType,
|
||||
MotionClip,
|
||||
MotionKind,
|
||||
PanoramaProjectionMode,
|
||||
SceneSettings,
|
||||
@@ -26,6 +28,7 @@ import type {
|
||||
} from "../schema/directorProject";
|
||||
import type { PosePresetId } from "../schema/poseSchema";
|
||||
import { getDirectorObjectFocusTarget } from "../schema/cameraTarget";
|
||||
import { deleteModelBinary, loadModelBinary } from "../loaders/modelBinaryStore";
|
||||
import { DEFAULT_CHARACTER_BODY_TYPE, normalizeBodyType } from "../runtime/mannequin/bodyTypes";
|
||||
import {
|
||||
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT,
|
||||
@@ -72,6 +75,8 @@ export interface ImportedAssetInput {
|
||||
name: string;
|
||||
fileName: string;
|
||||
url: string;
|
||||
/** GLB/GLTF 二进制在 IndexedDB 中的存储 id(可选,仅二进制模型有)。 */
|
||||
binaryId?: string;
|
||||
addToScene?: boolean;
|
||||
assetSource?: DirectorAssetSource;
|
||||
projectionMode?: PanoramaProjectionMode;
|
||||
@@ -103,6 +108,8 @@ export interface DirectorUiState {
|
||||
selectedCrowdId: string | null;
|
||||
/** 当前选中的轨迹点(决定右侧显示「轨迹点」卡片) */
|
||||
selectedTrajectoryPoint: DirectorTrajectoryPointRef | null;
|
||||
/** 当前选中的动作片段 id(决定动作轨道上高亮哪段、右侧动作参数卡片编辑哪段) */
|
||||
selectedMotionClipId: string | null;
|
||||
/** 轨迹打点模式的目标角色 id;非空时画布不可选取其它对象,双击地面即落一个轨迹点 */
|
||||
trajectoryPlacementObjectId: string | null;
|
||||
directorInspectorMode: "auto" | "scene";
|
||||
@@ -179,6 +186,20 @@ export interface DirectorActions {
|
||||
setMotionClipTrimStart: (objectId: string, startTime: number) => void;
|
||||
/** 设置动作片段的结束时间(拖动作条右端把手):动作播放到这一刻停止,起点不动 */
|
||||
setMotionClipEnd: (objectId: string, endTime: number) => void;
|
||||
/** 在动作轨道上添加一个动作片段(动作序列);默认从 options.start 开始、到时间轴末尾。 */
|
||||
addMotionClip: (objectId: string, options?: { kind?: MotionKind; start?: number; end?: number }) => void;
|
||||
/** 更新某个动作片段的属性(种类 / 速度 / 幅度 / 启用)。 */
|
||||
updateMotionClip: (
|
||||
objectId: string,
|
||||
clipId: string,
|
||||
patch: Partial<Pick<MotionClip, "kind" | "speed" | "amplitude" | "enabled">>
|
||||
) => void;
|
||||
/** 整体平移 / 缩放某个动作片段的时间区间(拖拽条本体或两端把手时写入)。 */
|
||||
updateMotionClipTime: (objectId: string, clipId: string, start: number, end: number, oldStart?: number) => void;
|
||||
/** 删除某个动作片段。 */
|
||||
removeMotionClip: (objectId: string, clipId: string) => void;
|
||||
/** 选中 / 取消选中某个动作片段;clipId 为 null 表示取消。 */
|
||||
selectMotionClip: (clipId: string | null) => void;
|
||||
removeTrajectoryPoint: (objectId: string, pointId: string) => void;
|
||||
/** 选中某个轨迹点;传 null 表示取消选中(点到轨道空白处 / 面板其它地方时清掉轨迹关键帧的选中态) */
|
||||
selectTrajectoryPoint: (objectId: string | null, pointId: string | null) => void;
|
||||
@@ -195,7 +216,9 @@ export interface DirectorActions {
|
||||
updateUniformScale: (id: string, scale: number) => void;
|
||||
updateCrowdUniformScale: (crowdId: string, scale: number) => void;
|
||||
addImportedAsset: (input: ImportedAssetInput) => void;
|
||||
addObjectFromAsset: (assetId: string) => string | null;
|
||||
/** 启动时把持久化的 GLB/GLTF 二进制从 IndexedDB 还原成 objectURL(localStorage 不存大文件)。 */
|
||||
hydratePersistedModelBinaries: () => Promise<void>;
|
||||
addObjectFromAsset: (assetId: string, options?: { asCharacter?: boolean }) => string | null;
|
||||
addPresetCharacter: (bodyType?: CharacterBodyType) => void;
|
||||
addCrowdCharacters: (input: CrowdCharactersInput) => string[];
|
||||
addGeometryPrimitive: (geometryType: GeometryPrimitiveType) => void;
|
||||
@@ -302,6 +325,7 @@ const DEFAULT_UI_STATE: DirectorUiState = {
|
||||
selectedObjectIds: [],
|
||||
selectedCrowdId: null,
|
||||
selectedTrajectoryPoint: null,
|
||||
selectedMotionClipId: null,
|
||||
trajectoryPlacementObjectId: null,
|
||||
directorInspectorMode: "auto",
|
||||
transformMode: "translate",
|
||||
@@ -356,7 +380,7 @@ function roundTransformTuple(values: [number, number, number]): [number, number,
|
||||
return values.map((value) => roundTransformValue(value)) as [number, number, number];
|
||||
}
|
||||
|
||||
function formatSceneItemName(prefix: "角色" | "机位", index: number) {
|
||||
function formatSceneItemName(prefix: "角色" | "机位" | "导入角色", index: number) {
|
||||
return `${prefix}${String(index).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -433,7 +457,10 @@ function persistLocalModelAsset(asset: DirectorAssetRef) {
|
||||
if (!isLocalModelLibraryAsset(asset)) return;
|
||||
|
||||
const persistedAssets = readPersistedLocalModelAssets().filter((item) => item.id !== asset.id);
|
||||
writePersistedLocalModelAssets([...persistedAssets, asset]);
|
||||
// GLB/GLTF 二进制走 IndexedDB:localStorage 只保留轻量元数据,url 置空避免 base64 撑爆配额。
|
||||
// 运行时通过 hydratePersistedModelBinaries 用 binaryId 还原 objectURL。
|
||||
const persisted: DirectorAssetRef = asset.binaryId ? { ...asset, url: "" } : asset;
|
||||
writePersistedLocalModelAssets([...persistedAssets, persisted]);
|
||||
}
|
||||
|
||||
function readPersistedTrajectoryPointRef(value: unknown): DirectorTrajectoryPointRef | null {
|
||||
@@ -453,6 +480,8 @@ function readPersistedStringArray(value: unknown): string[] {
|
||||
|
||||
function removePersistedLocalModelAsset(assetId: string) {
|
||||
writePersistedLocalModelAssets(readPersistedLocalModelAssets().filter((asset) => asset.id !== assetId));
|
||||
// 一并清理 IndexedDB 中的二进制(GLB 有 binaryId 才需要;失败静默忽略)。
|
||||
void deleteModelBinary(assetId).catch(() => {});
|
||||
}
|
||||
|
||||
function isDirectorProjectShape(value: unknown): value is DirectorProject {
|
||||
@@ -577,6 +606,37 @@ function migrateLegacyCameraKeyframeValue(value: {
|
||||
};
|
||||
}
|
||||
|
||||
/** 把旧工程的单一 motion 字段转换为动作片段数组(动作序列的基础)。群众成员保持单一 motion,不生成片段。 */
|
||||
function legacyMotionToClips(rig: CharacterRigState | undefined, object: DirectorObject): MotionClip[] | undefined {
|
||||
const motion = rig?.motion;
|
||||
if (!motion || motion.kind === "none") return undefined;
|
||||
return [
|
||||
{
|
||||
id: createKeyframeId(),
|
||||
kind: motion.kind,
|
||||
speed: motion.speed ?? 1,
|
||||
amplitude: motion.amplitude ?? 1,
|
||||
enabled: motion.enabled !== false,
|
||||
start: Number.isFinite(object.motionStartTime) ? Math.max(object.motionStartTime ?? 0, 0) : 0,
|
||||
end: Number.isFinite(object.motionEndTime) ? (object.motionEndTime ?? 0) : Number.POSITIVE_INFINITY,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 把角色的 motionClips 字段补齐(旧工程可能没有):已有片段则保留,否则从单一 motion 迁移。 */
|
||||
function migrateCharacterMotionClips(object: DirectorObject): DirectorObject {
|
||||
const rig = object.characterRig;
|
||||
if (!rig) return object;
|
||||
// 群众成员由 setCrowdMotion 统一驱动单一 motion,不生成片段,避免与群体动作冲突。
|
||||
if (object.crowdId) {
|
||||
return rig.motionClips && rig.motionClips.length
|
||||
? object
|
||||
: { ...object, characterRig: { ...rig, motionClips: undefined } };
|
||||
}
|
||||
const clips = rig.motionClips && rig.motionClips.length ? rig.motionClips : legacyMotionToClips(rig, object);
|
||||
return { ...object, characterRig: { ...rig, motionClips: clips } };
|
||||
}
|
||||
|
||||
function migrateDirectorProject(project: DirectorProject): DirectorProject {
|
||||
const withTimelineProject = withSceneDefaults(withCameraLookRotations(withTimeline(project)));
|
||||
return {
|
||||
@@ -585,7 +645,9 @@ function migrateDirectorProject(project: DirectorProject): DirectorProject {
|
||||
if (object.kind !== "character") return object;
|
||||
|
||||
const rig = object.characterRig;
|
||||
if (rig?.rigType === "ue4-mannequin") return object;
|
||||
if (rig?.rigType === "ue4-mannequin") {
|
||||
return migrateCharacterMotionClips(object);
|
||||
}
|
||||
|
||||
return {
|
||||
...object,
|
||||
@@ -593,6 +655,9 @@ function migrateDirectorProject(project: DirectorProject): DirectorProject {
|
||||
rigType: "ue4-mannequin",
|
||||
posePresetId: rig?.posePresetId ?? "stand",
|
||||
controls: rig?.controls ?? {},
|
||||
// 保留旧 motion 作为 fallback,并迁移成动作片段数组支持多段动作序列。
|
||||
motion: rig?.motion,
|
||||
motionClips: rig?.motionClips ?? legacyMotionToClips(rig, object),
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -626,6 +691,7 @@ function extractPersistedDirectorState(state: DirectorRuntimeState): DirectorSta
|
||||
selectedObjectIds: state.selectedObjectIds,
|
||||
selectedCrowdId: state.selectedCrowdId,
|
||||
selectedTrajectoryPoint: state.selectedTrajectoryPoint,
|
||||
selectedMotionClipId: typeof state.selectedMotionClipId === "string" ? state.selectedMotionClipId : null,
|
||||
trajectoryPlacementObjectId: state.trajectoryPlacementObjectId,
|
||||
directorInspectorMode: state.directorInspectorMode,
|
||||
transformMode: state.transformMode,
|
||||
@@ -686,6 +752,7 @@ function readPersistedDirectorState(options: DirectorStateOptions = {}): Directo
|
||||
: [],
|
||||
selectedCrowdId: typeof state.selectedCrowdId === "string" ? state.selectedCrowdId : null,
|
||||
selectedTrajectoryPoint: readPersistedTrajectoryPointRef(state.selectedTrajectoryPoint),
|
||||
selectedMotionClipId: typeof state.selectedMotionClipId === "string" ? state.selectedMotionClipId : null,
|
||||
trajectoryPlacementObjectId:
|
||||
typeof state.trajectoryPlacementObjectId === "string" ? state.trajectoryPlacementObjectId : null,
|
||||
directorInspectorMode: state.directorInspectorMode === "scene" ? "scene" : "auto",
|
||||
@@ -983,6 +1050,37 @@ function createSceneObjectFromAsset(asset: DirectorAssetRef, existingObjects: Di
|
||||
} satisfies DirectorObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从导入的 GLB/GLTF 资产创建一个「人形角色」对象:几何来自资产 url,动作由通用人形骨骼映射驱动。
|
||||
* 注意:不设置 assetRefId(否则会被当成道具渲染),而是把资产 id 放进 characterRig.modelAssetId。
|
||||
*/
|
||||
function createCharacterFromAsset(asset: DirectorAssetRef, existingObjects: DirectorObject[]) {
|
||||
const characterCount = existingObjects.filter((item) => item.kind === "character").length;
|
||||
const characterIndex = characterCount + 1;
|
||||
const objectId = getNextSequentialId(
|
||||
existingObjects.map((item) => item.id),
|
||||
"char_imported_",
|
||||
characterIndex
|
||||
);
|
||||
|
||||
return {
|
||||
id: objectId,
|
||||
name: formatSceneItemName("导入角色", characterIndex),
|
||||
kind: "character" as const,
|
||||
visible: true,
|
||||
locked: false,
|
||||
transform: createTransform([0, 0, 0]),
|
||||
characterRig: {
|
||||
rigType: "custom-humanoid" as const,
|
||||
posePresetId: "stand",
|
||||
controls: {},
|
||||
modelAssetId: asset.id,
|
||||
motion: { kind: "walk", speed: 1, amplitude: 1, enabled: true },
|
||||
},
|
||||
color: getNextCharacterColor(existingObjects),
|
||||
} satisfies DirectorObject;
|
||||
}
|
||||
|
||||
function refreshCamerasFocusedOnObject(cameras: DirectorCameraShot[], object: DirectorObject) {
|
||||
return cameras.map((camera) =>
|
||||
camera.targetMode === "object" && camera.targetObjectId === object.id
|
||||
@@ -1968,6 +2066,143 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
}),
|
||||
},
|
||||
})),
|
||||
addMotionClip: (objectId, options) =>
|
||||
commitMutation((state) => {
|
||||
const duration = state.project.timeline.duration;
|
||||
const start = Number.isFinite(options?.start) ? Math.max(options?.start ?? 0, 0) : 0;
|
||||
const end = Number.isFinite(options?.end) ? (options?.end ?? Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY;
|
||||
const kind = options?.kind ?? "walk";
|
||||
const newClip: MotionClip = {
|
||||
id: createKeyframeId(),
|
||||
kind,
|
||||
speed: 1,
|
||||
amplitude: 1,
|
||||
enabled: true,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
selectedMotionClipId: newClip.id,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||||
if (!item.characterRig) return item;
|
||||
const existing = item.characterRig.motionClips ?? [];
|
||||
// 自动把紧邻的前一个「到末尾」片段截断到新片段起点,形成连续序列(后者胜)。
|
||||
const nextClips = existing.map((clip) =>
|
||||
clip.end === Number.POSITIVE_INFINITY && clip.start < start ? { ...clip, end: start } : clip
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
characterRig: { ...item.characterRig, motionClips: [...nextClips, newClip] },
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
updateMotionClip: (objectId, clipId, patch) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||||
if (!item.characterRig?.motionClips) return item;
|
||||
return {
|
||||
...item,
|
||||
characterRig: {
|
||||
...item.characterRig,
|
||||
motionClips: item.characterRig.motionClips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, ...patch } : clip
|
||||
),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
})),
|
||||
updateMotionClipTime: (objectId, clipId, start, end, oldStart) =>
|
||||
commitMutation((state) => {
|
||||
const duration = state.project.timeline.duration;
|
||||
// 先把起点限制在时间轴内,再保证终点不早于起点(避免拖出时间轴后区间倒挂)。
|
||||
const clampedStart = Math.min(Math.max(start, 0), Math.max(duration - MOTION_CLIP_MIN_DURATION, 0));
|
||||
const clampedEnd = Number.isFinite(end)
|
||||
? Math.min(Math.max(end, clampedStart + MOTION_CLIP_MIN_DURATION), duration)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
// 拖拽开始前的起点,用于判断拖动方向(右移 / 左移)。
|
||||
const oldS = oldStart !== undefined && Number.isFinite(oldStart) ? Math.max(oldStart, 0) : null;
|
||||
return {
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||||
if (!item.characterRig?.motionClips) return item;
|
||||
let clips = item.characterRig.motionClips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, start: clampedStart, end: clampedEnd } : clip
|
||||
);
|
||||
// 拖动导致与另一片段重叠时,保证不重叠:把被压住的片段「推」到当前片段的另一侧贴边,
|
||||
// 从而自然实现「交换位置」——把一段拖过另一段时,两者互换先后,且全程不重叠。
|
||||
if (oldS !== null) {
|
||||
const overlap = clips.find(
|
||||
(c) => c.id !== clipId && c.start < clampedEnd && c.end > clampedStart
|
||||
);
|
||||
if (overlap) {
|
||||
const overlapFinite = Number.isFinite(overlap.end);
|
||||
const overlapLen = overlapFinite ? overlap.end - overlap.start : 0;
|
||||
// 起点是否变化:整段平移 / 左端把手会动起点;仅右端把手(终点扩张)起点不动。
|
||||
const startChanged = Math.abs(clampedStart - oldS) > 1e-6;
|
||||
if (!startChanged) {
|
||||
// 仅拖动右端把手(终点向右扩张):右侧重叠片段被挤到当前片段右侧。
|
||||
const s = clampedEnd;
|
||||
const e = overlapFinite
|
||||
? Math.min(clampedEnd + overlapLen, duration)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
clips = clips.map((c) => (c.id === overlap.id ? { ...c, start: s, end: e } : c));
|
||||
} else if (clampedStart >= oldS) {
|
||||
// 向右拖(整段或左端把手右移):重叠片段推到当前片段左侧(紧贴其左端)。
|
||||
const s = overlapFinite ? Math.max(0, clampedStart - overlapLen) : 0;
|
||||
const e = clampedStart;
|
||||
clips = clips.map((c) => (c.id === overlap.id ? { ...c, start: s, end: e } : c));
|
||||
} else {
|
||||
// 向左拖:重叠片段推到当前片段右侧(紧贴其右端,必要时延伸到末尾)。
|
||||
const s = clampedEnd;
|
||||
const e = overlapFinite
|
||||
? Math.min(clampedEnd + overlapLen, duration)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
clips = clips.map((c) => (c.id === overlap.id ? { ...c, start: s, end: e } : c));
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
characterRig: { ...item.characterRig, motionClips: clips },
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
removeMotionClip: (objectId, clipId) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
selectedMotionClipId: state.selectedMotionClipId === clipId ? null : state.selectedMotionClipId,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||||
if (!item.characterRig?.motionClips) return item;
|
||||
return {
|
||||
...item,
|
||||
characterRig: {
|
||||
...item.characterRig,
|
||||
motionClips: item.characterRig.motionClips.filter((clip) => clip.id !== clipId),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
})),
|
||||
selectMotionClip: (clipId) =>
|
||||
commitUiMutation((state) => ({
|
||||
...state,
|
||||
selectedMotionClipId: clipId,
|
||||
})),
|
||||
removeTrajectoryPoint: (objectId, pointId) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
@@ -2270,6 +2505,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
fileName: input.fileName,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
binaryId: input.binaryId,
|
||||
assetSource: input.kind === "panorama" ? undefined : (input.assetSource ?? "local"),
|
||||
projectionMode: input.projectionMode,
|
||||
} satisfies DirectorAssetRef;
|
||||
@@ -2316,14 +2552,44 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
},
|
||||
};
|
||||
}),
|
||||
addObjectFromAsset: (assetId) => {
|
||||
hydratePersistedModelBinaries: async () => {
|
||||
const assets = get().project.assets;
|
||||
const needsHydration = assets.filter((asset) => asset.binaryId && !asset.url);
|
||||
|
||||
if (!needsHydration.length) return;
|
||||
|
||||
await Promise.all(
|
||||
needsHydration.map(async (asset) => {
|
||||
try {
|
||||
const blob = await loadModelBinary(asset.binaryId!);
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
assets: state.project.assets.map((item) => (item.id === asset.id ? { ...item, url } : item)),
|
||||
},
|
||||
}));
|
||||
} catch {
|
||||
// 单个还原失败不影响其余资产
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
addObjectFromAsset: (assetId, options) => {
|
||||
let nextObjectId: string | null = null;
|
||||
|
||||
commitMutation((state) => {
|
||||
const asset = state.project.assets.find((item) => item.id === assetId);
|
||||
if (!asset || asset.sourceType !== "model" || asset.kind === "panorama") return state;
|
||||
|
||||
const nextObject = createSceneObjectFromAsset(asset, state.project.objects);
|
||||
// 「作为角色导入」仅对 GLB/GLTF 等可识别人形的资产有意义(FBX/OBJ 忽略该选项)。
|
||||
const asCharacter = options?.asCharacter === true && /\.(glb|gltf)$/i.test(asset.fileName);
|
||||
const nextObject = asCharacter
|
||||
? createCharacterFromAsset(asset, state.project.objects)
|
||||
: createSceneObjectFromAsset(asset, state.project.objects);
|
||||
nextObjectId = nextObject.id;
|
||||
|
||||
return {
|
||||
|
||||
@@ -4337,6 +4337,77 @@ body.is-timeline-dragging * {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 带「添加」按钮的动作子轨道标签:文字与按钮两端对齐 */
|
||||
.timeline-subtrack-label-action {
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
/* 动作轨道上的「添加动作片段」图形按钮(带 + 图标) */
|
||||
.timeline-add-clip-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(var(--accent-rgb) / 0.6);
|
||||
border-radius: 5px;
|
||||
background: rgb(var(--accent-rgb) / 0.15);
|
||||
color: rgb(var(--accent-rgb));
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.timeline-add-clip-button:hover {
|
||||
background: rgb(var(--accent-rgb) / 0.28);
|
||||
}
|
||||
.timeline-add-clip-button:focus-visible {
|
||||
outline: 2px solid rgb(var(--accent-rgb));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* 每个动作片段条右上角的删除按钮:默认隐藏,悬停 / 选中时显示 */
|
||||
.timeline-clip-delete {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 3px;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgb(220 60 60 / 0.85);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.timeline-trajectory-bar:hover .timeline-clip-delete,
|
||||
.timeline-trajectory-bar:focus-within .timeline-clip-delete,
|
||||
.timeline-clip-bar-motion.is-selected .timeline-clip-delete {
|
||||
display: inline-flex;
|
||||
}
|
||||
/* 角色「基础动作」卡片内的片段提示与添加按钮 */
|
||||
.motion-clip-hint {
|
||||
margin: 0 0 8px;
|
||||
color: rgb(var(--text-dim-rgb));
|
||||
font-size: 11px;
|
||||
}
|
||||
.motion-add-button {
|
||||
margin: 4px 0 10px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid rgb(var(--accent-rgb) / 0.6);
|
||||
border-radius: 6px;
|
||||
background: rgb(var(--accent-rgb) / 0.12);
|
||||
color: rgb(var(--accent-rgb));
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.motion-add-button:hover {
|
||||
background: rgb(var(--accent-rgb) / 0.24);
|
||||
}
|
||||
/* 分组抬头行:展开时轨道留空只作分组抬头,折叠时显示一行摘要。
|
||||
头部车道兼作「轨迹条」的容器,需要成为绝对定位的基准,且去掉左右内边距,
|
||||
否则轨迹条 / 关键帧按百分比定位会相对网格错开 8px */
|
||||
|
||||
Reference in new issue
Block a user