feat(编辑器): 支持导入 GLB/GLTF 模型并驱动角色动画

- 新增 GLB/GLTF 模型加载与渲染,支持内嵌动画播放
- 通过 IndexedDB 持久化大体积模型二进制,规避 localStorage 限制
- 新增动作片段序列,时间轴可添加、编辑与编排多段动作
- 优化机位视角相机同步,修复角度修改不生效问题
- 扩展骨骼识别,兼容 Mixamo 前缀及 UE4 骨架
This commit is contained in:
tongtong committed 2026-09-18 18:23:13 +08:00
1 parent 9d3a5b9bea
commit 38dc27501a
36 files changed
+1899 -427

No files matched your search

+6
View File
@@ -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;
+15
View File
@@ -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}
+48 -1
View File
@@ -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 ? (
+15 -3
View File
@@ -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)}
+29 -4
View File
@@ -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;
}
+46 -4
View File
@@ -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,
};
}
+75
View File
@@ -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("删除模型二进制失败"));
});
}
+1 -1
View File
@@ -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);
+2 -2
View 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">
+128 -80
View File
@@ -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}
</>
) : (
+5 -2
View File
@@ -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 />);
+152 -79
View File
@@ -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}
/>
);
+42 -6
View File
@@ -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>
);
+120
View File
@@ -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>
);
}
+199
View File
@@ -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);
}
}
+43 -237
View File
@@ -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);
});
});
+40 -1
View File
@@ -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;
+28
View File
@@ -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);
});
});
+272 -6
View File
@@ -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 {
+71
View File
@@ -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 */