mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-09 00:09:55 +00:00
Timeline animation clips (#122)
This is a super initial version of animation clips in timeline. https://github.com/storytold/storyteller-bevy/assets/10376144/b649162b-8758-4662-bfb9-6a492563b9f7 https://github.com/storytold/storyteller-bevy/assets/10376144/a5815685-bf52-4413-8828-d86051bf850a --------- Co-authored-by: Danny McGee <dannymcgee@gmail.com>
This commit is contained in:
@@ -149,7 +149,8 @@ export class AnimEditorElement extends LitElement
|
||||
let min = Infinity
|
||||
let max = -Infinity;
|
||||
for (const keyframe of Object.keys(this.keyframes)) {
|
||||
let time = this.keyframes[keyframe].time;
|
||||
const current = this.keyframes[keyframe];
|
||||
let time = current.time;
|
||||
if (typeof time != "number") {
|
||||
continue;
|
||||
}
|
||||
@@ -159,6 +160,12 @@ export class AnimEditorElement extends LitElement
|
||||
if (time < min) {
|
||||
min = time;
|
||||
}
|
||||
if ("duration" in current) {
|
||||
const end_time = time + current.duration;
|
||||
if (end_time > max) {
|
||||
max = end_time
|
||||
}
|
||||
}
|
||||
}
|
||||
this.clipStart = Math.max(min, 0.);
|
||||
this.clipEnd = Math.max(max, min);
|
||||
@@ -210,13 +217,20 @@ export class AnimEditorElement extends LitElement
|
||||
let closest_keyframe = -1 * Infinity;
|
||||
|
||||
for (const keyframe of Object.keys(this.keyframes)) {
|
||||
const time = this.keyframes[keyframe]?.time;
|
||||
const current = this.keyframes[keyframe];
|
||||
const time = current?.time;
|
||||
if (!time || typeof time != "number") {
|
||||
continue;
|
||||
}
|
||||
if (time < pos && time > closest_keyframe) {
|
||||
closest_keyframe = time;
|
||||
}
|
||||
if ("duration" in current) {
|
||||
const end_time = time + current.duration;
|
||||
if (end_time < pos && end_time > closest_keyframe) {
|
||||
closest_keyframe = end_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closest_keyframe < 0) {
|
||||
@@ -233,13 +247,20 @@ export class AnimEditorElement extends LitElement
|
||||
let closest_keyframe = Infinity;
|
||||
|
||||
for (const keyframe of Object.keys(this.keyframes)) {
|
||||
const time = this.keyframes[keyframe]?.time;
|
||||
const current = this.keyframes[keyframe];
|
||||
const time = current?.time;
|
||||
if (!time || typeof time != "number") {
|
||||
continue;
|
||||
}
|
||||
if (time > pos && time < closest_keyframe) {
|
||||
closest_keyframe = time;
|
||||
}
|
||||
if ("duration" in current) {
|
||||
const end_time = time + current.duration;
|
||||
if (end_time > pos && end_time < closest_keyframe) {
|
||||
closest_keyframe = end_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closest_keyframe > this.clipEnd) {
|
||||
|
||||
@@ -136,3 +136,27 @@
|
||||
background: #0FF; // TODO
|
||||
}
|
||||
}
|
||||
|
||||
.clip {
|
||||
position: absolute;
|
||||
transform: translateX(-4.5px);
|
||||
--clip-name: "";
|
||||
height: 12px;
|
||||
|
||||
&::before {
|
||||
content: var(--clip-name);
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: #AAA;
|
||||
color: black;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.selected::before {
|
||||
background: #0FF; // TODO
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,23 +406,50 @@ export class AnimTimelineElement extends LitElement {
|
||||
([id]) => id,
|
||||
([id, keyframe]) => {
|
||||
let row = this.track_animation_target_ordered[keyframe.target];
|
||||
return html`
|
||||
<div id=${id}
|
||||
class=${classMap({
|
||||
keyframe: true,
|
||||
selected: this._selectedKeyframe === id
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${100 * (keyframe.time - this.rangeStart) / range}%`,
|
||||
gridRow: row + 2,
|
||||
})}
|
||||
tabindex="-1"
|
||||
${drag({
|
||||
move: event => this.onKeyframeDrag(id, event),
|
||||
})}
|
||||
@click=${() => this.selectKeyframe(id)}
|
||||
></div>
|
||||
`;
|
||||
if ("clip" in keyframe) {
|
||||
let clip = keyframe.clip;
|
||||
let duration = keyframe.duration;
|
||||
let time = keyframe.time;
|
||||
|
||||
return html`
|
||||
<div id=${id}
|
||||
class=${classMap({
|
||||
clip: true,
|
||||
selected: this._selectedKeyframe === id
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${100 * (time - this.rangeStart) / range}%`,
|
||||
right: `${100 * (this.rangeEnd - (time + duration)) / range}%`,
|
||||
gridRow: row + 2,
|
||||
'--clip-name': `"${clip}"`,
|
||||
})}
|
||||
tabindex="-1"
|
||||
${drag({
|
||||
move: event => this.onKeyframeDrag(id, event),
|
||||
})}
|
||||
@click=${() => this.selectKeyframe(id)}
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return html`
|
||||
<div id=${id}
|
||||
class=${classMap({
|
||||
keyframe: true,
|
||||
selected: this._selectedKeyframe === id
|
||||
})}
|
||||
style=${styleMap({
|
||||
left: `${100 * (keyframe.time - this.rangeStart) / range}%`,
|
||||
gridRow: row + 2,
|
||||
})}
|
||||
tabindex="-1"
|
||||
${drag({
|
||||
move: event => this.onKeyframeDrag(id, event),
|
||||
})}
|
||||
@click=${() => this.selectKeyframe(id)}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,12 @@ $hierarchy-width: 290px;
|
||||
--timeline-height: max-content;
|
||||
}
|
||||
|
||||
.debug-mode {
|
||||
.studio-scene {
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
@@ -56,6 +56,8 @@ export class AppElement extends LitElement {
|
||||
@state() resizingPanel = false;
|
||||
@state() inspectorWidth = 320;
|
||||
|
||||
@state() debugMode = false;
|
||||
|
||||
@state() sidebars: Array<{ title: string, name: string}> = [{title: 'Hierarchy', name: 'hierarchy'}];
|
||||
|
||||
@state() currentSidebar : string = 'hierarchy';
|
||||
@@ -103,6 +105,11 @@ export class AppElement extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
@on("window:debug-mode-toggled")
|
||||
onDebugModeToggled({detail: { debugModeActive }}: studio.DebugModeToggled ): void {
|
||||
this.debugMode = debugModeActive;
|
||||
}
|
||||
|
||||
onPanelResizeStart(): void {
|
||||
this.resizingPanel = true;
|
||||
}
|
||||
@@ -133,7 +140,7 @@ export class AppElement extends LitElement {
|
||||
|
||||
protected override render() {
|
||||
return html`
|
||||
<sts-canvas-provider class="app-root">
|
||||
<sts-canvas-provider class=${classMap({"app-root": true, "debug-mode": this.debugMode})}>
|
||||
<sts-scene-data-provider>
|
||||
<sts-overlay-provider>
|
||||
<sts-iframe-messaging-bridge></sts-iframe-messaging-bridge>
|
||||
|
||||
@@ -147,6 +147,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
MediaFileType.Glb,
|
||||
MediaFileType.Gltf,
|
||||
MediaFileType.ScnRon,
|
||||
MediaFileType.Mocap
|
||||
],
|
||||
});
|
||||
|
||||
@@ -198,11 +199,11 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
this.#transientFileInput = document.createElement("input");
|
||||
this.#transientFileInput.type = "file";
|
||||
this.#transientFileInput.accept = match (this._activeCategory.mediaClass, {
|
||||
[MediaFileClass.Animation]: () => ".bvh,.fbx,.gltf",
|
||||
[MediaFileClass.Animation]: () => ".bvh,.fbx,.gltf,.glb",
|
||||
[MediaFileClass.Audio]: () => ".wav,.mp3",
|
||||
[MediaFileClass.Character]: () => ".fbx,.gltf",
|
||||
[MediaFileClass.Prop]: () => ".fbx,.gltf",
|
||||
[MediaFileClass.Scene]: () => ".fbx,.gltf,.scn.ron",
|
||||
[MediaFileClass.Character]: () => ".fbx,.gltf,.glb",
|
||||
[MediaFileClass.Prop]: () => ".fbx,.gltf,.glb",
|
||||
[MediaFileClass.Scene]: () => ".fbx,.gltf,.glb,.scn.ron",
|
||||
_: () => "",
|
||||
});
|
||||
this.#transientFileInput.multiple = false;
|
||||
@@ -267,8 +268,48 @@ export class MediaLibraryDialogElement extends DialogBaseElement {
|
||||
}
|
||||
|
||||
#addAnimation(): void {
|
||||
// TODO: Make this actually work
|
||||
console.warn("Adding animations not yet implemented!");
|
||||
const token = this._selected[0];
|
||||
if (!this._mediaFiles) {
|
||||
console.error("Expected `_mediaFiles` map to be present when spawning assets");
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaFile = this._mediaFiles.get(token);
|
||||
if (!mediaFile) {
|
||||
console.error(`Attempted to spawn an invalid token: "${token}"`);
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = mediaFile.extension;
|
||||
if (!ext) {
|
||||
console.error("Expected media file to have a non-null `extension` field");
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = {
|
||||
path: `remote://${token}${ext}`,
|
||||
name: mediaFile.displayName,
|
||||
};
|
||||
|
||||
const skeletonSelected = this._sceneData.selectedObject?.["id"];
|
||||
if (!skeletonSelected) {
|
||||
console.error(`Attempted to an animation without a selected skeleton`);
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
return;
|
||||
}
|
||||
|
||||
let retargetingType = studio.ClipRetargeting.UseOrigin;
|
||||
if (mediaFile.subtype === MediaFileSubtype.Mixamo) {
|
||||
retargetingType = studio.ClipRetargeting.Mixamo;
|
||||
} else if (mediaFile.subtype === MediaFileSubtype.MocapNet) {
|
||||
retargetingType = studio.ClipRetargeting.MocapNet
|
||||
}
|
||||
|
||||
console.log(`Inserting Animation ${asset.name} for skeleton ${skeletonSelected}`)
|
||||
studio.insertClip(skeletonSelected, asset.path, asset.name, retargetingType);
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ use bevy::{prelude::*, utils::hashbrown::HashMap};
|
||||
|
||||
use bevy_tweening::{
|
||||
lens::{TransformPositionLens, TransformRotationLens, TransformScaleLens},
|
||||
Animator, EaseMethod, Sequence, Targetable, Tracks, Tween, TweenCompleted,
|
||||
Animator, BoxedTweenable, Delay, EaseMethod, Sequence, Targetable, Tracks, Tween,
|
||||
TweenCompleted,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -16,6 +17,8 @@ use crate::{
|
||||
scene::SceneElement,
|
||||
};
|
||||
|
||||
use super::Skeleton;
|
||||
|
||||
pub struct TimelinePlugin;
|
||||
|
||||
impl Plugin for TimelinePlugin {
|
||||
@@ -30,6 +33,8 @@ impl Plugin for TimelinePlugin {
|
||||
.editor_registry::<Keyframe>()
|
||||
.register_type::<KeyframeOrder>()
|
||||
.editor_registry::<KeyframeOrder>()
|
||||
.register_type::<ClipRetargeting>()
|
||||
.editor_registry::<ClipRetargeting>()
|
||||
.register_type::<EaseType>()
|
||||
.register_type::<uuid::Uuid>()
|
||||
.add_event::<TimelineInteractionEvent>()
|
||||
@@ -43,7 +48,10 @@ impl Plugin for TimelinePlugin {
|
||||
})
|
||||
.add_event::<TweenCompleted>();
|
||||
|
||||
app.add_systems(First, generate_transform_animation);
|
||||
app.add_systems(
|
||||
First,
|
||||
(generate_transform_animation, generate_clip_animation),
|
||||
);
|
||||
app.add_systems(PreUpdate, (process_timeline_events, update_tick).chain());
|
||||
|
||||
app.add_systems(
|
||||
@@ -52,10 +60,12 @@ impl Plugin for TimelinePlugin {
|
||||
process_auto_key.run_if(in_state(KeyingMode::Auto)),
|
||||
process_keyframe_events,
|
||||
timeline_bound_component_animator_system::<Transform>,
|
||||
timeline_bound_component_animator_system::<AnimationPlayer>,
|
||||
update_timed_animation_clip_duration,
|
||||
),
|
||||
);
|
||||
|
||||
// app.add_systems(PostUpdate, generate_transform_animation);
|
||||
app.add_systems(PostUpdate, retarget_timed_animation_clip);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
@@ -91,11 +101,24 @@ pub enum TimelineInteractionEvent {
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Event, Clone, Copy, Debug)]
|
||||
#[derive(Event, Clone, Debug)]
|
||||
pub enum KeyframeInteractionEvent {
|
||||
RecordKeyframe { target: Entity },
|
||||
MoveKeyframeTime { target: Entity, time: f32 },
|
||||
AddEntityTrack { target: Entity },
|
||||
RecordKeyframe {
|
||||
target: Entity,
|
||||
},
|
||||
MoveKeyframeTime {
|
||||
target: Entity,
|
||||
time: f32,
|
||||
},
|
||||
AddEntityTrack {
|
||||
target: Entity,
|
||||
},
|
||||
InsertAnimationClip {
|
||||
target: Entity,
|
||||
clip_path: String,
|
||||
display_name: String,
|
||||
retargeting_type: ClipRetargeting,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Resource, Clone, Copy, Debug, Default, Reflect, Serialize, Deserialize)]
|
||||
@@ -170,16 +193,40 @@ impl Default for Keyframe {
|
||||
#[reflect(Component)]
|
||||
pub struct TimedAnimationClipAsset {
|
||||
clip: Handle<AnimationClip>,
|
||||
duration: f32,
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug, PartialEq)]
|
||||
pub struct TimedAnimationClipDuration(f32);
|
||||
|
||||
#[derive(Component, Clone, Debug)]
|
||||
enum RetargetedClipReference {
|
||||
UseOrigin,
|
||||
RenamedRoot {
|
||||
retargeted: Handle<AnimationClip>,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
Mixamo {
|
||||
retargeted: Handle<AnimationClip>,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
MocapNet {
|
||||
retargeted: Handle<AnimationClip>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, Debug, Reflect, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[reflect(Component)]
|
||||
#[cfg_attr(all(feature = "wasm"), wasm_bindgen::prelude::wasm_bindgen)]
|
||||
pub enum ClipRetargeting {
|
||||
#[default]
|
||||
UseOrigin,
|
||||
Mixamo,
|
||||
MocapNet,
|
||||
}
|
||||
|
||||
impl PartialOrd for TimedAnimationClipAsset {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
match self.duration.partial_cmp(&other.duration) {
|
||||
Some(core::cmp::Ordering::Equal) => {}
|
||||
ord => return ord,
|
||||
}
|
||||
match self.speed.partial_cmp(&other.speed) {
|
||||
Some(core::cmp::Ordering::Equal) => {}
|
||||
ord => return ord,
|
||||
@@ -192,7 +239,6 @@ impl Default for TimedAnimationClipAsset {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
clip: Default::default(),
|
||||
duration: Default::default(),
|
||||
speed: 1.0,
|
||||
}
|
||||
}
|
||||
@@ -303,6 +349,7 @@ fn process_keyframe_events(
|
||||
q_animation_targets: Query<(Entity, &Transform, &AnimationTarget)>,
|
||||
q_potential_targets: Query<Entity, (With<Transform>, Without<AnimationTarget>)>,
|
||||
mut q_keyframes: Query<(Entity, &mut Keyframe)>,
|
||||
asset_server: Res<AssetServer>,
|
||||
) {
|
||||
for event in e_timeline_event.read() {
|
||||
info!("Processing Keyframe Event {event:?}");
|
||||
@@ -359,6 +406,34 @@ fn process_keyframe_events(
|
||||
warn!("Can't add animation target to entity");
|
||||
}
|
||||
}
|
||||
KeyframeInteractionEvent::InsertAnimationClip {
|
||||
target,
|
||||
clip_path,
|
||||
display_name,
|
||||
retargeting_type,
|
||||
} => {
|
||||
if let Ok((_target, transform, animation_target)) = q_animation_targets.get(*target)
|
||||
{
|
||||
commands.spawn((
|
||||
Name::new(display_name.to_owned()),
|
||||
PrefabMarker,
|
||||
SpatialBundle {
|
||||
transform: *transform,
|
||||
..Default::default()
|
||||
},
|
||||
Selectable,
|
||||
Keyframe {
|
||||
time: r_global_seek_time.time,
|
||||
target: *animation_target,
|
||||
},
|
||||
TimedAnimationClipAsset {
|
||||
clip: asset_server.load(format!("{clip_path}#Animation0")),
|
||||
speed: 1.0,
|
||||
},
|
||||
retargeting_type.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,6 +675,280 @@ pub fn timeline_bound_component_animator_system<T: Component>(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct TimedAnimationClipLens {
|
||||
clip: Handle<AnimationClip>,
|
||||
duration: f32,
|
||||
}
|
||||
|
||||
impl bevy_tweening::lens::Lens<AnimationPlayer> for TimedAnimationClipLens {
|
||||
fn lerp(&mut self, target: &mut AnimationPlayer, ratio: f32) {
|
||||
let inner_time = self.duration * ratio;
|
||||
if target.animation_clip() != &self.clip {
|
||||
target.play(self.clip.clone());
|
||||
target.pause();
|
||||
target.seek_to(inner_time);
|
||||
} else {
|
||||
target.seek_to(inner_time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_clip_animation(
|
||||
mut commands: Commands,
|
||||
keyframes: Query<
|
||||
(
|
||||
&Keyframe,
|
||||
&TimedAnimationClipAsset,
|
||||
&TimedAnimationClipDuration,
|
||||
&RetargetedClipReference,
|
||||
Entity,
|
||||
),
|
||||
Without<MarkedForDeletion>,
|
||||
>,
|
||||
valid_targets: Query<(
|
||||
Entity,
|
||||
&AnimationTarget,
|
||||
Option<&Name>,
|
||||
Option<&SceneElement>,
|
||||
&Skeleton,
|
||||
)>,
|
||||
mut e_timeline_event: EventReader<TimelineInteractionEvent>,
|
||||
) {
|
||||
let mut playing = false;
|
||||
for e in e_timeline_event.read() {
|
||||
if matches!(
|
||||
e,
|
||||
TimelineInteractionEvent::Play | TimelineInteractionEvent::Seek(_)
|
||||
) {
|
||||
playing = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !playing {
|
||||
return;
|
||||
}
|
||||
info!("Generating Animation From Keyframes");
|
||||
|
||||
let available_targets = valid_targets
|
||||
.iter()
|
||||
.map(|(e, a, name, element, _skeleton)| (*a, (e, name, element)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let keyframes = keyframes
|
||||
.iter()
|
||||
.into_group_map_by(|(keyframe, _, _, _, _)| keyframe.target);
|
||||
|
||||
for (target, keyframes) in keyframes.into_iter() {
|
||||
info!("Keyframes For Target {target:?}");
|
||||
let Some(target) = available_targets.get(&target) else {
|
||||
info!("No valid target.");
|
||||
continue;
|
||||
};
|
||||
let (target, target_name, target_scene_element) = *target;
|
||||
|
||||
let keyframes = keyframes.iter();
|
||||
|
||||
let keyframes = keyframes.sorted_by(|a, b| a.0.time.total_cmp(&b.0.time));
|
||||
|
||||
let (last, mut clip_timeline) = keyframes.fold(
|
||||
(
|
||||
Option::<(f32, f32, Handle<AnimationClip>)>::None,
|
||||
Vec::<BoxedTweenable<AnimationPlayer>>::new(),
|
||||
),
|
||||
|(last_clip, mut clip_timeline), (keyframe, asset, duration, reference, _entity)| {
|
||||
let time = keyframe.time;
|
||||
|
||||
let (last_start, last_duration, last_clip) = match last_clip {
|
||||
Some((start, duration, clip)) => (start, duration, Some(clip)),
|
||||
None => (0., 0., None),
|
||||
};
|
||||
|
||||
let mut reached_now = false;
|
||||
let mut clip_end = last_start + last_duration;
|
||||
if clip_end > time {
|
||||
reached_now = true;
|
||||
clip_end = time;
|
||||
}
|
||||
|
||||
if let Some(last_clip) = last_clip {
|
||||
let duration = clip_end - last_start;
|
||||
|
||||
if duration > 0f32 {
|
||||
clip_timeline.push(
|
||||
Tween::new(
|
||||
EaseMethod::Linear,
|
||||
Duration::from_secs_f32(duration),
|
||||
TimedAnimationClipLens {
|
||||
clip: last_clip,
|
||||
duration,
|
||||
},
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !reached_now {
|
||||
let duration = time - clip_end;
|
||||
if duration > 0f32 {
|
||||
clip_timeline.push(Delay::new(Duration::from_secs_f32(duration)).into());
|
||||
}
|
||||
}
|
||||
|
||||
let duration = duration.0;
|
||||
let clip = match reference {
|
||||
RetargetedClipReference::UseOrigin => &asset.clip,
|
||||
RetargetedClipReference::RenamedRoot { retargeted } => retargeted,
|
||||
RetargetedClipReference::Mixamo { retargeted } => retargeted,
|
||||
RetargetedClipReference::MocapNet { retargeted } => retargeted,
|
||||
};
|
||||
|
||||
(Some((time, duration, clip.clone())), clip_timeline)
|
||||
},
|
||||
);
|
||||
|
||||
if let Some((_, duration, clip)) = last {
|
||||
if duration > 0f32 {
|
||||
clip_timeline.push(
|
||||
Tween::new(
|
||||
EaseMethod::Linear,
|
||||
Duration::from_secs_f32(duration),
|
||||
TimedAnimationClipLens { clip, duration },
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if clip_timeline.is_empty() {
|
||||
commands
|
||||
.entity(target)
|
||||
.remove::<Animator<AnimationPlayer>>()
|
||||
.remove::<AnimationPlayer>();
|
||||
continue;
|
||||
}
|
||||
|
||||
let clip_timeline = Sequence::new(clip_timeline);
|
||||
info!(
|
||||
"Adding Animation Clip Track for {target_name:?} {target_scene_element:?} {target:?}"
|
||||
);
|
||||
commands
|
||||
.entity(target)
|
||||
.insert((AnimationPlayer::default(), Animator::new(clip_timeline)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_timed_animation_clip_duration(
|
||||
mut commands: Commands,
|
||||
query: Query<(Entity, &TimedAnimationClipAsset), Without<TimedAnimationClipDuration>>,
|
||||
clips: Res<Assets<AnimationClip>>,
|
||||
) {
|
||||
for (entity, clip_asset) in &query {
|
||||
if let Some(clip) = clips.get(&clip_asset.clip) {
|
||||
let duration = clip.duration();
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(TimedAnimationClipDuration(duration));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retarget_timed_animation_clip(
|
||||
mut commands: Commands,
|
||||
clip_keys: Query<
|
||||
(
|
||||
Entity,
|
||||
&TimedAnimationClipAsset,
|
||||
&ClipRetargeting,
|
||||
&Keyframe,
|
||||
),
|
||||
(
|
||||
With<TimedAnimationClipDuration>,
|
||||
Without<RetargetedClipReference>,
|
||||
),
|
||||
>,
|
||||
animation_targets: Query<(Entity, &AnimationTarget, &Name), With<Skeleton>>,
|
||||
mut clips: ResMut<Assets<AnimationClip>>,
|
||||
) {
|
||||
let targets = animation_targets
|
||||
.iter()
|
||||
.map(|(entity, target, name)| (*target, (entity, name)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
for (entity, asset, retarget, keyframe) in &clip_keys {
|
||||
info!("Added Animation: {asset:?} with retargeting {retarget:?}");
|
||||
let Some(clip) = clips.get(&asset.clip) else {
|
||||
continue;
|
||||
};
|
||||
info!("Animation exists");
|
||||
let Some(paths) = clip.field("paths") else {
|
||||
continue;
|
||||
};
|
||||
info!("Parsing Paths");
|
||||
let Some(paths): Option<HashMap<EntityPath, usize>> =
|
||||
HashMap::<EntityPath, usize>::from_reflect(paths)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some((_target_entity, target_name)) = targets.get(&keyframe.target) else {
|
||||
error!("Animation has no target: {keyframe:?}");
|
||||
continue;
|
||||
};
|
||||
|
||||
match &retarget {
|
||||
ClipRetargeting::UseOrigin => {
|
||||
info!("Use Origin");
|
||||
if clip.compatible_with(target_name) {
|
||||
info!("Animation Already Compatible");
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(RetargetedClipReference::UseOrigin);
|
||||
} else {
|
||||
let mut new_clip = AnimationClip::default();
|
||||
let first_path = paths.keys().next().cloned();
|
||||
let new_first_path = first_path.as_ref().map(|path| {
|
||||
let mut new_path = path.clone();
|
||||
if let Some(root_name) = new_path.parts.first_mut() {
|
||||
root_name.set(target_name.to_string());
|
||||
}
|
||||
new_path
|
||||
});
|
||||
for (path, bone_id) in paths.iter() {
|
||||
let mut new_path = path.clone();
|
||||
if let Some(root_name) = new_path.parts.first_mut() {
|
||||
root_name.set(target_name.to_string());
|
||||
}
|
||||
|
||||
let Some(curves) = clip.get_curves(*bone_id) else {
|
||||
continue;
|
||||
};
|
||||
for curve in curves {
|
||||
new_clip.add_curve_to_path(new_path.clone(), curve.clone());
|
||||
}
|
||||
}
|
||||
info!("Inserted Animation With Renamed Root - original {first_path:?} becomes {new_first_path:?}");
|
||||
let retargeted = clips.add(new_clip);
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(RetargetedClipReference::RenamedRoot { retargeted });
|
||||
}
|
||||
}
|
||||
ClipRetargeting::Mixamo => {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(RetargetedClipReference::UseOrigin);
|
||||
}
|
||||
ClipRetargeting::MocapNet => {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(RetargetedClipReference::UseOrigin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use bevy::{prelude::*, utils::hashbrown::HashMap, window::PrimaryWindow};
|
||||
@@ -613,8 +962,9 @@ mod wasm {
|
||||
};
|
||||
|
||||
use super::{
|
||||
AnimationTarget, EaseType, GlobalSeekTime, Keyframe, KeyframeInteractionEvent, KeyingMode,
|
||||
TimedAnimationClipAsset, TimelineInteractionEvent, TransformKeyframe,
|
||||
AnimationTarget, ClipRetargeting, EaseType, GlobalSeekTime, Keyframe,
|
||||
KeyframeInteractionEvent, KeyingMode, TimedAnimationClipAsset, TimedAnimationClipDuration,
|
||||
TimelineInteractionEvent, TransformKeyframe,
|
||||
};
|
||||
|
||||
static SEEK_TIME: WasmMessageBuffer<TimelineInteractionEvent> = WasmMessageBuffer::new();
|
||||
@@ -652,6 +1002,24 @@ mod wasm {
|
||||
);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = insertClip)]
|
||||
pub fn insert_clip(
|
||||
entity: String,
|
||||
clip_path: String,
|
||||
display_name: String,
|
||||
retargeting_type: ClipRetargeting,
|
||||
) {
|
||||
let Ok(target) = Entity::from_str(&entity) else {
|
||||
return;
|
||||
};
|
||||
KEYFRAME_EVENT.write_message(vec![KeyframeInteractionEvent::InsertAnimationClip {
|
||||
target,
|
||||
clip_path,
|
||||
display_name,
|
||||
retargeting_type,
|
||||
}]);
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = moveKeyframeTime)]
|
||||
pub fn move_keyframe_time(entity: String, time: f32) {
|
||||
let Ok(target) = Entity::from_str(&entity) else {
|
||||
@@ -746,10 +1114,10 @@ mod wasm {
|
||||
automatic: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"seek-time-changed": CameraKeyframesEvent;
|
||||
"seek-time-changed": CameraKeyframesEvent;
|
||||
"update-animation-targets": UpdateAnimationTargets;
|
||||
"update-keyframes": UpdateKeyframes;
|
||||
}
|
||||
@@ -780,8 +1148,8 @@ mod wasm {
|
||||
target: AnimationTarget,
|
||||
time: f32,
|
||||
clip: String,
|
||||
duration: f32,
|
||||
speed: f32,
|
||||
duration: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -887,8 +1255,18 @@ mod wasm {
|
||||
Or<(Changed<Keyframe>, Changed<TransformKeyframe>)>,
|
||||
>,
|
||||
q_clips: Query<
|
||||
(Entity, &Keyframe, &TimedAnimationClipAsset),
|
||||
Or<(Changed<Keyframe>, Changed<TimedAnimationClipAsset>)>,
|
||||
(
|
||||
Entity,
|
||||
&Keyframe,
|
||||
&TimedAnimationClipAsset,
|
||||
&Name,
|
||||
Option<&TimedAnimationClipDuration>,
|
||||
),
|
||||
Or<(
|
||||
Changed<Keyframe>,
|
||||
Changed<TimedAnimationClipAsset>,
|
||||
Changed<TimedAnimationClipDuration>,
|
||||
)>,
|
||||
>,
|
||||
removed: Query<Entity, With<MarkedForDeletion>>,
|
||||
) -> Result<(), String> {
|
||||
@@ -914,21 +1292,25 @@ mod wasm {
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
.chain(q_clips.iter().map(|(entity, keyframe, clip)| {
|
||||
match serde_json::to_value(TimedAnimationClipInfo {
|
||||
entity: format!("{entity:?}"),
|
||||
target: keyframe.target,
|
||||
time: keyframe.time,
|
||||
clip: clip.clip.id().to_string(),
|
||||
duration: clip.duration,
|
||||
speed: clip.speed,
|
||||
})
|
||||
.map_err(|e| format!("{e}"))
|
||||
{
|
||||
Ok(value) => Ok((format!("{entity:?}"), value)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}))
|
||||
.chain(
|
||||
q_clips
|
||||
.iter()
|
||||
.map(|(entity, keyframe, clip, name, duration)| {
|
||||
match serde_json::to_value(TimedAnimationClipInfo {
|
||||
entity: format!("{entity:?}"),
|
||||
target: keyframe.target,
|
||||
time: keyframe.time,
|
||||
clip: name.to_string(),
|
||||
speed: clip.speed,
|
||||
duration: duration.map(|v| v.0).unwrap_or_default(),
|
||||
})
|
||||
.map_err(|e| format!("{e}"))
|
||||
{
|
||||
Ok(value) => Ok((format!("{entity:?}"), value)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.collect::<Result<HashMap<_, _>, String>>()?;
|
||||
|
||||
let removed = removed.iter().map(|v| format!("{v:?}")).collect::<Vec<_>>();
|
||||
|
||||
@@ -80,6 +80,14 @@ impl Plugin for DebugPlugin {
|
||||
Update,
|
||||
update_editor_ui_mode.run_if(resource_changed::<DebugMode>()),
|
||||
);
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
app.add_systems(
|
||||
PostUpdate,
|
||||
wasm::notify_debug_mode_toggle.map(bevy::utils::error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,3 +217,45 @@ fn sync_editor_camera(
|
||||
cmd.entity(ent).insert(EditorCameraMarker);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use super::DebugMode;
|
||||
use bevy::{prelude::*, window::PrimaryWindow};
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const DEBUG_TYPES: &str = r#"
|
||||
export interface DebugModeToggled extends CustomEvent {
|
||||
type: "debug-mode-toggled",
|
||||
detail: {
|
||||
debugModeActive: boolean
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface GlobalEventHandlersEventMap {
|
||||
"debug-mode-toggled": DebugModeToggled
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
pub fn notify_debug_mode_toggle(
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
r_debug_mode: Res<DebugMode>,
|
||||
) -> Result<(), String> {
|
||||
if !r_debug_mode.is_changed() {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(window) = q_window.get_single() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
crate::wasm::dispatch_to_js(
|
||||
window,
|
||||
"debug-mode-toggled",
|
||||
&json!({ "debugModeActive": r_debug_mode.0 }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user