From e999a11349091e0aa2b1e90349bf448ec52413df Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Wed, 22 Apr 2026 16:13:37 +0200 Subject: [PATCH] Timeline: pre-animation baseline restore on Stop + display cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop now rewinds animated properties to the value they had before the timeline first acted on them — not "first keyframe's value" as before. - New per-track baseline stored runtime-only in a TMap keyed by (ActorId, PropertyPath), so it survives track reordering and isn't persisted to JSON (avoids stale data in legacy scenarios). - Captured once: at track creation (first "+" on a fresh property) or at SetData time for tracks loaded from a scenario. Never re-captured, so scrub-then-Play doesn't shift the baseline to whatever the cursor happened to be parked on. - Stop writes the baselines back via ImportText, fires OnEditorPropertyChanged + RepNotify per track, then defers the actual StopSimulation by 100ms so the BehaviorTree has a grace period to see the restored values via OnRep / OnEditorPropertyChanged and transition to a neutral state before AI tick is cut. - Play cancels any in-flight deferred StopSimulation (Restart = Stop + Play keeps the simulation running across the handoff). - SetData and Clear reset the baseline map so loading a new scenario doesn't inherit the previous one's baselines. Also cache GetCurrentDisplayForProperty calls in RefreshDynamicPropertyValues: the property panel was invoking the BP interface every tick per visible combo. Now each row stores LastExportedValue + LastDisplayToShow and short-circuits if the property's exported text is unchanged — BP only runs on actual value transitions (seek, timeline keyframe, manual edit). Duplicated across MainWidget and MainWidget_Legacy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Timeline/PS_Editor_TimelineSubsystem.cpp | 133 +++++++++++++++++- .../Timeline/PS_Editor_TimelineSubsystem.h | 23 +++ .../UI/Widgets/PS_Editor_MainWidget.cpp | 64 +++++---- .../UI/Widgets/PS_Editor_MainWidget.h | 6 + .../Widgets/PS_Editor_MainWidget_Legacy.cpp | 67 +++++---- .../UI/Widgets/PS_Editor_MainWidget_Legacy.h | 5 + 6 files changed, 242 insertions(+), 56 deletions(-) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp index 17cd564..dc6333b 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.cpp @@ -92,6 +92,23 @@ void UPS_Editor_TimelineSubsystem::Play() { CurrentTime = 0.f; } + + // Cancel any pending deferred StopSimulation (user hit Play again during the BT grace + // period after a previous Stop — don't cut the AI now). + if (UWorld* World = GetWorld()) + { + if (PendingStopSimulationHandle.IsValid()) + { + World->GetTimerManager().ClearTimer(PendingStopSimulationHandle); + PendingStopSimulationHandle.Invalidate(); + } + } + + // Make sure every track has a captured baseline. New tracks typically get their baseline + // at creation time (in AddOrUpdateKeyAtCurrentTime), tracks loaded from a scenario get + // theirs at SetData — this call is a safety net for anything that slipped through. + CaptureBaselinesForAllMissingTracks(); + bIsPlaying = true; // Timeline and simulation are inherently linked: start simulation if it isn't running, @@ -135,20 +152,116 @@ void UPS_Editor_TimelineSubsystem::Stop() LastAppliedValues.Empty(); LastAppliedValues.SetNum(Data.Tracks.Num()); - // Full reset: stop simulation (restores pre-play transforms) and apply t=0 values so - // the scene visually matches the timeline start. + // Restore pre-play baseline values onto the actors BEFORE stopping simulation, so the + // BT sees the restored values (via OnEditorPropertyChanged / OnRep) and has a chance + // to transition to a neutral state during the grace period below. + RestoreBaseline(); + + // Defer the actual AI cut by a few frames so the BT can react to the restored values. + // If Play() is called again during this window, the timer is cancelled there. if (APS_Editor_PlayerController* EditorPC = FindEditorPC(GetWorld())) { if (EditorPC->IsSimulating()) { - EditorPC->StopSimulation(); + if (UWorld* World = GetWorld()) + { + TWeakObjectPtr WeakPC(EditorPC); + World->GetTimerManager().SetTimer(PendingStopSimulationHandle, + [WeakPC]() + { + if (APS_Editor_PlayerController* PC = WeakPC.Get()) + { + if (PC->IsSimulating()) + { + PC->StopSimulation(); + } + } + }, + 0.1f, false); + } + else + { + EditorPC->StopSimulation(); + } } } - ApplyValuesAtTime(CurrentTime); OnTimelineChanged.Broadcast(); } +void UPS_Editor_TimelineSubsystem::CaptureBaselineForTrack(int32 TrackIndex) +{ + if (!Data.Tracks.IsValidIndex(TrackIndex)) return; + const FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex]; + const TPair Key(Track.ActorId, Track.PropertyPath); + if (PrePlayBaseline.Contains(Key)) return; // already captured + + AActor* Actor = FindActorById(Track.ActorId); + if (!Actor) return; + UObject* Target = nullptr; + FProperty* Prop = nullptr; + ResolveTarget(Actor, Track.PropertyPath, Target, Prop); + if (!Target || !Prop) return; + + FString Value; + Prop->ExportText_InContainer(0, Value, Target, Target, nullptr, PPF_None); + PrePlayBaseline.Add(Key, MoveTemp(Value)); +} + +void UPS_Editor_TimelineSubsystem::CaptureBaselinesForAllMissingTracks() +{ + for (int32 i = 0; i < Data.Tracks.Num(); ++i) + { + CaptureBaselineForTrack(i); + } +} + +void UPS_Editor_TimelineSubsystem::RestoreBaseline() +{ + if (PrePlayBaseline.Num() == 0) return; + + // Server-only write path (same guard as ApplyValuesAtTime). + if (UWorld* World = GetWorld()) + { + if (World->GetNetMode() == NM_Client) return; + } + + for (const FPS_Editor_TimelineTrack& Track : Data.Tracks) + { + const TPair Key(Track.ActorId, Track.PropertyPath); + const FString* BaselineValue = PrePlayBaseline.Find(Key); + if (!BaselineValue) continue; + + AActor* Actor = FindActorById(Track.ActorId); + if (!Actor) continue; + UObject* Target = nullptr; + FProperty* Prop = nullptr; + ResolveTarget(Actor, Track.PropertyPath, Target, Prop); + if (!Target || !Prop) continue; + + Prop->ImportText_InContainer(**BaselineValue, Target, Target, PPF_None); + if (UActorComponent* AsComp = Cast(Target)) + { + AsComp->MarkRenderStateDirty(); + } + Actor->ForceNetUpdate(); + if (!Prop->RepNotifyFunc.IsNone()) + { + if (UFunction* RepFunc = Target->FindFunction(Prop->RepNotifyFunc)) + { + Target->ProcessEvent(RepFunc, nullptr); + } + } + if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) + { + IPS_Editor_EditableInterface::Execute_OnEditorPropertyChanged(Actor, Track.PropertyPath.ToString()); + } + } + + // Don't clear the map — a subsequent Stop (or Restart) must still restore the same baseline. + // SetData/Clear are the only resetters. +} + void UPS_Editor_TimelineSubsystem::Restart() { // Rewind + replay from the start. Done in two steps so simulation transforms are restored @@ -194,6 +307,13 @@ void UPS_Editor_TimelineSubsystem::SetData(const FPS_Editor_TimelineData& NewDat bIsPlaying = false; LastAppliedValues.Empty(); LastAppliedValues.SetNum(Data.Tracks.Num()); + + // Drop the previous scenario's baselines and capture fresh ones from the actors that + // were just spawned by the scene loader. Their current values are by definition the + // pre-animation state for this scenario's tracks. + PrePlayBaseline.Reset(); + CaptureBaselinesForAllMissingTracks(); + OnTimelineChanged.Broadcast(); } @@ -203,6 +323,7 @@ void UPS_Editor_TimelineSubsystem::Clear() CurrentTime = 0.f; bIsPlaying = false; LastAppliedValues.Empty(); + PrePlayBaseline.Reset(); OnTimelineChanged.Broadcast(); } @@ -248,6 +369,10 @@ bool UPS_Editor_TimelineSubsystem::AddOrUpdateKeyAtCurrentTime(AActor* Actor, co NewTrack.PropertyPath = PropertyPath; TrackIndex = Data.Tracks.Add(NewTrack); LastAppliedValues.SetNum(Data.Tracks.Num()); + + // Capture the pre-animation baseline NOW (before any key is added): the current actor + // value is the canonical "before timeline existed" state for this property. + CaptureBaselineForTrack(TrackIndex); } FPS_Editor_TimelineTrack& Track = Data.Tracks[TrackIndex]; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h index 7c6912a..bb4e2d9 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Timeline/PS_Editor_TimelineSubsystem.h @@ -149,6 +149,17 @@ private: /** Cache of last applied exported-text value per track, used to avoid re-firing OnEditorPropertyChanged every frame when the value hasn't actually changed. */ TArray LastAppliedValues; + /** Runtime-only pre-animation baseline per track, keyed by (ActorId, PropertyPath) so it + * survives track re-ordering and isn't persisted to JSON (avoids stale data in legacy + * scenarios). Captured exactly once per track — at track creation (first keyframe) or + * at SetData time for tracks loaded from a scenario. Stop() reads from here; SetData + * and Clear reset it. */ + TMap, FString> PrePlayBaseline; + + /** Deferred StopSimulation timer — set by Stop() so the BT gets a few frames to react + * to the restored baseline before AI tick is cut. Cancelled if Play() is called again. */ + FTimerHandle PendingStopSimulationHandle; + /** Map of stable IDs for every actor that participates in the timeline / scene registry. */ TMap, FGuid> ActorIds; @@ -160,4 +171,16 @@ private: /** Resolve the UObject target (actor or component) and the FProperty pointer. Nullable. */ static void ResolveTarget(AActor* Actor, const FName& PropertyPath, UObject*& OutTarget, FProperty*& OutProperty); + + /** Capture the current actor value as the baseline for the given track, unless already + * captured. Called at track creation and at SetData (for tracks loaded from JSON). */ + void CaptureBaselineForTrack(int32 TrackIndex); + + /** Walk every track and call CaptureBaselineForTrack if it doesn't already have an entry. */ + void CaptureBaselinesForAllMissingTracks(); + + /** Write baseline values back onto the actors + fire OnEditorPropertyChanged / RepNotify + * per track. No-op for tracks without a captured baseline. Does NOT clear the baseline + * map so subsequent Stops still work. */ + void RestoreBaseline(); }; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp index b049239..8a1fe66 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp @@ -2663,39 +2663,51 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues() FString CurrentVal; Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None); - // Resolve the DisplayName to show in the combo. Priority order: - // 1. Ask the actor directly via GetCurrentDisplayForProperty — the user knows - // their storage format best, so this is authoritative when overridden. - // 2. Otherwise, fall back to matching CurrentVal / normalized-path against - // the options returned by GetCustomOptionsWithValuesForProperty. - FString DisplayToShow = CurrentVal; - if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) + // Cache hit: the exported value hasn't changed since the last refresh, so the + // resolved DisplayName is identical too — skip the BP call + match loop entirely. + FString DisplayToShow; + if (!Row.LastExportedValue.IsEmpty() && CurrentVal == Row.LastExportedValue) { - const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key); - if (!FromActor.IsEmpty()) - { - DisplayToShow = FromActor; - } + DisplayToShow = Row.LastDisplayToShow; } - if (DisplayToShow == CurrentVal && Row.CustomOptionDisplayToValue.Num() > 0) + else { - // Normalize class-ref format `TypeName'/Path.X_C'` → `/Path.X_C`. - FString NormalizedCurrent = CurrentVal; - int32 FirstQuote = INDEX_NONE; - int32 LastQuote = INDEX_NONE; - if (CurrentVal.FindChar(TEXT('\''), FirstQuote) && CurrentVal.FindLastChar(TEXT('\''), LastQuote) - && LastQuote > FirstQuote + 1) + // Resolve the DisplayName to show in the combo. Priority order: + // 1. Ask the actor directly via GetCurrentDisplayForProperty — the user knows + // their storage format best, so this is authoritative when overridden. + // 2. Otherwise, fall back to matching CurrentVal / normalized-path against + // the options returned by GetCustomOptionsWithValuesForProperty. + DisplayToShow = CurrentVal; + if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) { - NormalizedCurrent = CurrentVal.Mid(FirstQuote + 1, LastQuote - FirstQuote - 1); - } - for (const TPair& P : Row.CustomOptionDisplayToValue) - { - if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent)) + const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key); + if (!FromActor.IsEmpty()) { - DisplayToShow = P.Key; - break; + DisplayToShow = FromActor; } } + if (DisplayToShow == CurrentVal && Row.CustomOptionDisplayToValue.Num() > 0) + { + // Normalize class-ref format `TypeName'/Path.X_C'` → `/Path.X_C`. + FString NormalizedCurrent = CurrentVal; + int32 FirstQuote = INDEX_NONE; + int32 LastQuote = INDEX_NONE; + if (CurrentVal.FindChar(TEXT('\''), FirstQuote) && CurrentVal.FindLastChar(TEXT('\''), LastQuote) + && LastQuote > FirstQuote + 1) + { + NormalizedCurrent = CurrentVal.Mid(FirstQuote + 1, LastQuote - FirstQuote - 1); + } + for (const TPair& P : Row.CustomOptionDisplayToValue) + { + if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent)) + { + DisplayToShow = P.Key; + break; + } + } + } + Row.LastExportedValue = CurrentVal; + Row.LastDisplayToShow = DisplayToShow; } if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h index 98c4d03..752d16b 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.h @@ -38,6 +38,12 @@ struct FPS_Editor_PropertyRowWidgets * (display == value) or a native enum combo. */ TMap CustomOptionDisplayToValue; + + /** Last exported text value applied to this row's combo; used to avoid recomputing + * the display name (and calling GetCurrentDisplayForProperty on the actor) each + * frame when the property hasn't actually changed. */ + FString LastExportedValue; + FString LastDisplayToShow; }; /** diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp index 3242996..93e4a91 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp @@ -2322,39 +2322,54 @@ void UPS_Editor_MainWidget_Legacy::RefreshDynamicPropertyValues() FString CurrentVal; Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None); - // Resolve the DisplayName to show in the combo. Priority order: - // 1. Ask the actor directly via GetCurrentDisplayForProperty — the user knows - // their storage format best, so this is authoritative when overridden. - // 2. Otherwise, fall back to matching CurrentVal / normalized-path against - // the options returned by GetCustomOptionsWithValuesForProperty. - FString DisplayToShow = CurrentVal; - if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) + // Cache hit: the exported value hasn't changed since the last refresh, so the + // DisplayName we'd resolve is identical too. Skip the BP call + match loop + // entirely. This is what keeps GetCurrentDisplayForProperty off the hot tick + // path for props that don't move. + FString DisplayToShow; + if (!Row.LastExportedValue.IsEmpty() && CurrentVal == Row.LastExportedValue) { - const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key); - if (!FromActor.IsEmpty()) - { - DisplayToShow = FromActor; - } + DisplayToShow = Row.LastDisplayToShow; } - if (DisplayToShow == CurrentVal && Row.CustomOptionDisplayToValue.Num() > 0) + else { - // Normalize class-ref format `TypeName'/Path.X_C'` → `/Path.X_C`. - FString NormalizedCurrent = CurrentVal; - int32 FirstQuote = INDEX_NONE; - int32 LastQuote = INDEX_NONE; - if (CurrentVal.FindChar(TEXT('\''), FirstQuote) && CurrentVal.FindLastChar(TEXT('\''), LastQuote) - && LastQuote > FirstQuote + 1) + // Resolve the DisplayName to show in the combo. Priority order: + // 1. Ask the actor directly via GetCurrentDisplayForProperty — the user knows + // their storage format best, so this is authoritative when overridden. + // 2. Otherwise, fall back to matching CurrentVal / normalized-path against + // the options returned by GetCustomOptionsWithValuesForProperty. + DisplayToShow = CurrentVal; + if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass())) { - NormalizedCurrent = CurrentVal.Mid(FirstQuote + 1, LastQuote - FirstQuote - 1); - } - for (const TPair& P : Row.CustomOptionDisplayToValue) - { - if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent)) + const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key); + if (!FromActor.IsEmpty()) { - DisplayToShow = P.Key; - break; + DisplayToShow = FromActor; } } + if (DisplayToShow == CurrentVal && Row.CustomOptionDisplayToValue.Num() > 0) + { + // Normalize class-ref format `TypeName'/Path.X_C'` → `/Path.X_C`. + FString NormalizedCurrent = CurrentVal; + int32 FirstQuote = INDEX_NONE; + int32 LastQuote = INDEX_NONE; + if (CurrentVal.FindChar(TEXT('\''), FirstQuote) && CurrentVal.FindLastChar(TEXT('\''), LastQuote) + && LastQuote > FirstQuote + 1) + { + NormalizedCurrent = CurrentVal.Mid(FirstQuote + 1, LastQuote - FirstQuote - 1); + } + for (const TPair& P : Row.CustomOptionDisplayToValue) + { + if (!P.Value.IsEmpty() && (P.Value == CurrentVal || P.Value == NormalizedCurrent)) + { + DisplayToShow = P.Key; + break; + } + } + } + // Remember what we resolved so the next frame can short-circuit. + Row.LastExportedValue = CurrentVal; + Row.LastDisplayToShow = DisplayToShow; } if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.h index d89b8dd..1c1644e 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.h @@ -32,6 +32,11 @@ struct FPS_Editor_PropertyRowWidgetsLegacy /** DisplayName -> stored Value mapping from GetCustomOptionsWithValuesForProperty. */ TMap CustomOptionDisplayToValue; + + /** Cache to avoid calling GetCurrentDisplayForProperty + the match loop every frame + * when the property hasn't changed. */ + FString LastExportedValue; + FString LastDisplayToShow; }; /**