Timeline: pre-animation baseline restore on Stop + display cache

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 16:13:37 +02:00
parent bc491b7b94
commit e999a11349
6 changed files with 242 additions and 56 deletions

View File

@@ -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())
{
if (UWorld* World = GetWorld())
{
TWeakObjectPtr<APS_Editor_PlayerController> 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<FGuid, FName> 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<FGuid, FName> 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<UActorComponent>(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];

View File

@@ -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<FString> 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<TPair<FGuid, FName>, 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<TWeakObjectPtr<AActor>, 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();
};

View File

@@ -2663,12 +2663,21 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
FString CurrentVal;
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
// 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)
{
DisplayToShow = Row.LastDisplayToShow;
}
else
{
// 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;
DisplayToShow = CurrentVal;
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key);
@@ -2697,6 +2706,9 @@ void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
}
}
}
Row.LastExportedValue = CurrentVal;
Row.LastDisplayToShow = DisplayToShow;
}
if (Row.EnumComboText.IsValid() && Row.EnumComboText->GetText().ToString() != DisplayToShow)
{

View File

@@ -38,6 +38,12 @@ struct FPS_Editor_PropertyRowWidgets
* (display == value) or a native enum combo.
*/
TMap<FString, FString> 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;
};
/**

View File

@@ -2322,12 +2322,23 @@ void UPS_Editor_MainWidget_Legacy::RefreshDynamicPropertyValues()
FString CurrentVal;
Row.CachedProperty->ExportText_InContainer(0, CurrentVal, Target, Target, nullptr, PPF_None);
// 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)
{
DisplayToShow = Row.LastDisplayToShow;
}
else
{
// 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;
DisplayToShow = CurrentVal;
if (Actor->GetClass()->ImplementsInterface(UPS_Editor_EditableInterface::StaticClass()))
{
const FString FromActor = IPS_Editor_EditableInterface::Execute_GetCurrentDisplayForProperty(Actor, Row.Key);
@@ -2356,6 +2367,10 @@ void UPS_Editor_MainWidget_Legacy::RefreshDynamicPropertyValues()
}
}
}
// 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)
{

View File

@@ -32,6 +32,11 @@ struct FPS_Editor_PropertyRowWidgetsLegacy
/** DisplayName -> stored Value mapping from GetCustomOptionsWithValuesForProperty. */
TMap<FString, FString> CustomOptionDisplayToValue;
/** Cache to avoid calling GetCurrentDisplayForProperty + the match loop every frame
* when the property hasn't changed. */
FString LastExportedValue;
FString LastDisplayToShow;
};
/**