- Published on
Project Rapture Climate, Time, and Weather System
Recently for Project Rapture, I built out a fully modular climate system that ties together time of day, weather, and final world lighting.
Originally, the time system and weather system were both capable of changing the same things in the level. They could both affect fog, sun intensity, skylight values, and post-process settings. That technically worked at first, but it created a pretty obvious long term problem: if two different systems are trying to control the same visual actor, whichever system updates last wins.
That is not scalable.
So, I reworked the system into three separate pieces:
Time Manager
Tracks time, day, time states, and creates a baseline visual snapshot.
Weather Manager
Tracks weather state, transitions, wind, VFX, thunder, and lightning requests.
Climate Manager
Combines time + weather and becomes the only system that applies final world visuals.
The main goal was to keep each system focused on one responsibility while still making all three systems work together as one complete world atmosphere system.
Why I split the system into three parts
The first version of the system had the TimeManager directly applying lighting changes. It could rotate the sun, change fog density, update the skylight, and modify post-process values.
The WeatherManager could also do similar things. Heavy rain could darken the sun, fog could change fog density, storms could affect post-process, and lightning could flash the directional light.
The problem is that both systems were touching the same world actors.
Directional Light
Sky Light
Exponential Height Fog
Post Process Volume
That means the architecture was fragile. Time of day could set the fog to one value, then weather could immediately overwrite it. Weather could darken the sun, then time could brighten it again on the next tick.
Instead of letting the systems fight each other, I made the ClimateManager the final authority.
Now the flow is:
TimeManager -> provides the time baseline
WeatherManager -> provides the weather influence
ClimateManager -> applies the final result
This keeps the whole system predictable and much easier to debug.
The Time Manager
The TimeManager is responsible for tracking the current in game day, hour, minute, and active time-of-day state.
It does not directly change the world lighting anymore.
Instead, it builds a snapshot of what the world should look like based on the current time of day. The ClimateManager reads this snapshot and combines it with weather.
This is the main idea behind the time system:
USTRUCT(BlueprintType)
struct FRaptureTimeVisualSnapshot
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
float SunIntensity = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
FLinearColor SunColor = FLinearColor::White;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
FRotator SunRotation = FRotator::ZeroRotator;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
float MoonIntensity = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
FLinearColor MoonColor = FLinearColor(0.35f, 0.45f, 1.f, 1.f);
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
FRotator MoonRotation = FRotator::ZeroRotator;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
float SkyLightIntensity = 1.f;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
FLinearColor SkyLightColor = FLinearColor::White;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
float FogDensity = 0.02f;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
FLinearColor FogColor = FLinearColor(0.025f, 0.025f, 0.035f, 1.f);
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
float ExposureBias = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Time|Visual Snapshot")
float VignetteIntensity = 0.25f;
};
This struct gives the rest of the climate system a clean, readable package of time based visual data.
For example, at noon the snapshot might say:
SunIntensity = 5.5
SkyLightIntensity = 0.95
FogDensity = 0.015
VignetteIntensity = 0.15
At night, it might say:
SunIntensity = 0.0
MoonIntensity = 0.45
SkyLightIntensity = 0.18
FogDensity = 0.04
VignetteIntensity = 0.42
The important thing is that the TimeManager only calculates these values. It does not apply them.
Time is data driven
The time system is driven by a URaptureTimeDataAsset.
This data asset contains:
Progression Settings
- Initial day
- Initial hour
- Time speed
- Whether days loop
- Whether time starts automatically
Lighting Settings
- Sun intensity curve
- Moon intensity curve
- Skylight intensity curve
- Fog density curve
- Exposure curve
- Vignette curve
- Sun color curve
- Moon color curve
- Skylight color curve
- Fog color curve
Time States
- Dawn
- Day
- Dusk
- Night
- Eclipse
- Interior
- Cinematic
This makes it easy to create completely different time profiles for different parts of the game.
For example, the Homestead can use a softer profile:
DA_Time_Homestead_Default
- Slower time progression
- Warmer sunrise and sunset
- Softer night fog
- Lower vignette intensity
An extraction zone can use a harsher profile:
DA_Time_Forest_Extraction
- Faster time progression
- Darker nights
- Heavier fog
- Stronger vignette
And an Eclipse event can lock time completely:
DA_Time_Eclipse_Full
- Time progression disabled
- Very low sun and skylight intensity
- Heavy fog
- Strong vignette
- Red/dark fog color
This keeps time of day behavior flexible without hardcoding values into the manager.
Time States
Time states are also data driven.
Each state has a name, gameplay tag, start hour, end hour, and priority.
USTRUCT(BlueprintType)
struct FRaptureTimeOfDayState
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Time State")
FName StateName = "TimeState";
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Time State")
FText StateDescription;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Time State")
FGameplayTag StateTag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Time State", meta=(ClampMin="0.0", ClampMax="24.0"))
float StartHour = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Time State", meta=(ClampMin="0.0", ClampMax="24.0"))
float EndHour = 24.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Time State")
int32 Priority = 0;
};
A normal Homestead setup might look like this:
Dawn
Tag: Time.Dawn
Start: 5.0
End: 7.0
Day
Tag: Time.Day
Start: 7.0
End: 17.5
Dusk
Tag: Time.Dusk
Start: 17.5
End: 20.0
Night
Tag: Time.Night
Start: 20.0
End: 5.0
The night state intentionally wraps past midnight. That lets a single state cover something like 20.0 -> 5.0.
The time data asset handles this with a helper function:
bool URaptureTimeDataAsset::DoesHourFallInsideState(const float Hour, const FRaptureTimeOfDayState& State) const
{
const float NormalizedHour = NormalizeHour(Hour);
const float Start = NormalizeHour(State.StartHour);
const float End = NormalizeHour(State.EndHour);
if (FMath::IsNearlyEqual(Start, End))
{
return true;
}
if (Start < End)
{
return NormalizedHour >= Start && NormalizedHour < End;
}
return NormalizedHour >= Start || NormalizedHour < End;
}
This makes the system designer friendly because a designer can author time windows naturally without needing to think about edge cases around midnight.
Time Progression
The TimeManager advances time by converting real world delta time into in game minutes.
void ARaptureTimeManager::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (!LoadedTimeDataAsset)
{
return;
}
TimeChangedBroadcastAccumulator += DeltaTime;
VisualSnapshotUpdateAccumulator += DeltaTime;
if (!RuntimeProgressionSettings.bTimeProgressionEnabled || bIsTimePaused)
{
return;
}
if (RuntimeProgressionSettings.GameMinutesPerRealSecond <= 0.f)
{
return;
}
const float MinutesToAdvance = DeltaTime * RuntimeProgressionSettings.GameMinutesPerRealSecond;
AdvanceTimeByMinutes(MinutesToAdvance);
}
This means each data asset can control how fast time moves.
For example:
Homestead
GameMinutesPerRealSecond = 3.0
Forest Extraction
GameMinutesPerRealSecond = 6.0
Debug Fast Cycle
GameMinutesPerRealSecond = 60.0
Eclipse Full
GameMinutesPerRealSecond = 0.0
bTimeProgressionEnabled = false
The debug fast cycle is especially useful because it allows me to quickly test sunrise, day, dusk, and night transitions without waiting around in editor.
The Weather Manager
The WeatherManager is responsible for weather state, weather transitions, wind, Niagara weather VFX, thunder, and lightning requests.
It does not directly change the sun, skylight, fog, or post-process anymore.
That was the biggest architectural cleanup.
The WeatherManager owns weather specific things:
- Weather state
- Weather transitions
- Random weather cycling
- Wind strength and gusts
- Rain/fog/ash Niagara VFX
- Thunder timers
- Thunder sounds
- Lightning requests
The ClimateManager owns shared visuals:
- Sun
- Moon
- SkyLight
- Fog
- PostProcess
This separation is what prevents overlap.
Weather Is Also Data Driven
Weather states live in a URaptureWeatherDataAsset.
A weather state contains things like:
- State name
- State description
- Gameplay tag
- Random weight
- Duration range
- Transition duration
- Visual settings
- Audio settings
- Gameplay tags
A simplified weather state might look like this:
Heavy Storm
- Tag: Weather.Storm.Heavy
- RandomWeight: 10
- DurationRange: 120 - 240
- TransitionDuration: 10
- WindStrength: 2.0
- VFXIntensity: 1.0
- FogDensity: 0.12
- DirectionalLightIntensity: 0.5
- bEnableThunder: true
- bEnableLightning: true
The weather state still stores visual values like fog density and directional light intensity, but the WeatherManager does not apply those directly. Those values are now used by the ClimateManager as weather influence.
That means weather can say:
I want the world to be darker and foggier.
But the ClimateManager decides the final result after considering the current time of day.
Weather Transitions
The WeatherManager supports smooth transitions between weather states.
When weather changes, the manager stores:
- TransitionFromWeather
- TransitionToWeather
- TransitionElapsedTime
- ActiveTransitionDuration
During the transition, it blends from the old weather state to the new one.
float ARaptureWeatherManager::GetWeatherTransitionAlpha(const bool bSmoothed) const
{
if (!bIsTransitioning)
{
return 0.f;
}
const float RawAlpha = ActiveTransitionDuration <= 0.f
? 1.f
: FMath::Clamp(TransitionElapsedTime / ActiveTransitionDuration, 0.f, 1.f);
return bSmoothed ? SmoothWeatherAlpha(RawAlpha) : RawAlpha;
}
I use a smooth alpha so weather changes feel more natural:
float ARaptureWeatherManager::SmoothWeatherAlpha(float Alpha)
{
Alpha = FMath::Clamp(Alpha, 0.f, 1.f);
return Alpha * Alpha * (3.f - 2.f * Alpha);
}
This prevents weather from feeling like it snaps instantly from clear skies to heavy storm.
Weather State For The ClimateManager
One of the most important functions in the WeatherManager is GetWeatherStateForClimate().
FWeatherState ARaptureWeatherManager::GetWeatherStateForClimate() const
{
if (!bHasCurrentWeather)
{
return FWeatherState();
}
if (!bIsTransitioning)
{
return CurrentWeather;
}
const float Alpha = GetWeatherTransitionAlpha(true);
return BuildBlendedWeatherState(Alpha);
}
This function gives the ClimateManager the correct weather state to use.
If the weather is not transitioning, it returns the current weather.
If the weather is transitioning, it returns a blended weather state.
That means the ClimateManager can smoothly blend fog, light, skylight, and post-process values without the WeatherManager directly touching those actors.
Weather Owned Runtime Effects
The WeatherManager still directly controls wind and VFX because those are weather-specific and do not overlap with the ClimateManager.
void ARaptureWeatherManager::ApplyWeatherRuntimeEffects(
const FWeatherState& FromWeather,
const FWeatherState& ToWeather,
const float Alpha) const
{
const FWeatherVisualSettings& From = FromWeather.Visuals;
const FWeatherVisualSettings& To = ToWeather.Visuals;
if (WindDirectionalSource)
{
if (UWindDirectionalSourceComponent* WindComp = WindDirectionalSource->GetComponent())
{
WindComp->Strength = FMath::Lerp(From.WindStrength, To.WindStrength, Alpha);
WindComp->Speed = FMath::Lerp(From.WindSpeed, To.WindSpeed, Alpha);
WindComp->MinGustAmount = FMath::Lerp(From.MinGustAmount, To.MinGustAmount, Alpha);
WindComp->MaxGustAmount = FMath::Lerp(From.MaxGustAmount, To.MaxGustAmount, Alpha);
WindComp->MarkRenderStateDirty();
}
}
const float BlendedVFXIntensity = FMath::Lerp(From.VFXIntensity, To.VFXIntensity, Alpha);
const float BlendedWindIntensity = FMath::Lerp(From.WindStrength, To.WindStrength, Alpha);
UpdateActiveWeatherVFX(BlendedVFXIntensity, BlendedWindIntensity);
}
This keeps the WeatherManager useful without letting it fight the ClimateManager.
Lightning Requests
Originally, lightning directly changed the directional light intensity inside the WeatherManager.
That worked, but it caused the same ownership issue as fog and skylight. If ClimateManager applied climate during a lightning flash, it could overwrite the flash.
So now lightning works through an event.
The WeatherManager requests lightning:
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
FOnRaptureLightningFlashRequested,
float,
FlashIntensity,
float,
FlashDuration
);
When thunder triggers, WeatherManager broadcasts the request:
void ARaptureWeatherManager::RequestLightningFlash()
{
OnLightningFlashRequested.Broadcast(
CurrentWeather.Visuals.LightningFlashIntensity,
CurrentWeather.Visuals.LightningFlashDuration
);
}
Then the ClimateManager receives the request and applies the actual light flash on top of the final climate snapshot.
That keeps lightning in the correct ownership chain.thunder triggers, WeatherManager broadcasts the request:
The Climate Manager
The ClimateManager is the final authority.
It reads the TimeManager snapshot, reads the WeatherManager climate state, blends them together, applies lightning if needed, clamps the result, and writes to the actual world actors.
It is the only system that should apply values to:
- Sun directional light
- Moon directional light
- Sky light
- Exponential height fog
- Post process volume
This is the part of the system that finally makes time and weather work together instead of fighting each other.
Climate Snapshot
The ClimateManager builds a final FRaptureClimateVisualSnapshot.
This snapshot is the final combined state of the world atmosphere.
USTRUCT(BlueprintType)
struct FRaptureClimateVisualSnapshot
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
bool bHasTimeSource = false;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
bool bHasWeatherSource = false;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
FGameplayTag TimeStateTag;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
FGameplayTag WeatherStateTag;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
float SunIntensity = 0.f;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
FLinearColor SunColor = FLinearColor::White;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
FRotator SunRotation = FRotator::ZeroRotator;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
float SkyLightIntensity = 1.f;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
float FogDensity = 0.02f;
UPROPERTY(BlueprintReadOnly, Category="Climate|Snapshot")
float VignetteIntensity = 0.25f;
};
The real struct contains more values, but this shows the general idea.
The ClimateManager builds this snapshot every time it needs to apply the current climate.
Climate Snapshot
The first step is to ask the TimeManager for its current visual snapshot.
FRaptureClimateVisualSnapshot ARaptureClimateManager::BuildTimeBaselineSnapshot() const
{
FRaptureClimateVisualSnapshot Snapshot;
if (!IsValid(TimeManager))
{
return Snapshot;
}
const FRaptureTimeVisualSnapshot TimeSnapshot = TimeManager->GetCurrentTimeVisualSnapshot();
Snapshot.bHasTimeSource = true;
Snapshot.TimeStateTag = TimeManager->GetCurrentTimeStateTag();
Snapshot.TimeStateName = TimeManager->GetCurrentTimeOfDayState().StateName;
Snapshot.SunIntensity = TimeSnapshot.SunIntensity;
Snapshot.SunColor = TimeSnapshot.SunColor;
Snapshot.SunRotation = TimeSnapshot.SunRotation;
Snapshot.MoonIntensity = TimeSnapshot.MoonIntensity;
Snapshot.MoonColor = TimeSnapshot.MoonColor;
Snapshot.MoonRotation = TimeSnapshot.MoonRotation;
Snapshot.SkyLightIntensity = TimeSnapshot.SkyLightIntensity;
Snapshot.SkyLightColor = TimeSnapshot.SkyLightColor;
Snapshot.FogDensity = TimeSnapshot.FogDensity;
Snapshot.FogColor = TimeSnapshot.FogColor;
Snapshot.ExposureBias = TimeSnapshot.ExposureBias;
Snapshot.VignetteIntensity = TimeSnapshot.VignetteIntensity;
return Snapshot;
}
This is the foundation of the final world look.
If it is noon, the baseline is bright.
If it is night, the baseline is dark.
If it is an eclipse, the baseline is extremely oppressive.
Weather then modifies that baseline.
Applying Weather To The Baseline
Once the time baseline is built, the ClimateManager asks the WeatherManager for the current weather state that should be used for climate.
bool ARaptureClimateManager::ResolveWeatherStateForClimate(FWeatherState& OutWeatherState) const
{
if (!IsValid(WeatherManager) || !WeatherManager->HasActiveWeather())
{
OutWeatherState = FWeatherState();
return false;
}
OutWeatherState = WeatherManager->GetWeatherStateForClimate();
return OutWeatherState.IsValidWeatherState();
}
This is transition aware. If weather is changing from clear to storm, the ClimateManager receives the blended weather state instead of only the old or new state.
Then the ClimateManager blends weather into the time baseline.
void ARaptureClimateManager::ApplyWeatherToSnapshot(
FRaptureClimateVisualSnapshot& InOutSnapshot,
const FWeatherState& WeatherState) const
{
const FWeatherVisualSettings& WeatherVisuals = WeatherState.Visuals;
const float SunAlpha = FMath::Clamp(BlendSettings.WeatherSunInfluence, 0.f, 1.f);
const float SkyAlpha = FMath::Clamp(BlendSettings.WeatherSkyLightInfluence, 0.f, 1.f);
const float FogAlpha = FMath::Clamp(BlendSettings.WeatherFogInfluence, 0.f, 1.f);
const float PPAlpha = FMath::Clamp(BlendSettings.WeatherPostProcessInfluence, 0.f, 1.f);
InOutSnapshot.SunIntensity = FMath::Lerp(
InOutSnapshot.SunIntensity,
WeatherVisuals.DirectionalLightIntensity,
SunAlpha
);
InOutSnapshot.SkyLightIntensity = FMath::Lerp(
InOutSnapshot.SkyLightIntensity,
WeatherVisuals.SkyLightIntensity,
SkyAlpha
);
InOutSnapshot.FogDensity = FMath::Lerp(
InOutSnapshot.FogDensity,
WeatherVisuals.FogDensity,
FogAlpha
);
InOutSnapshot.VignetteIntensity = FMath::Lerp(
InOutSnapshot.VignetteIntensity,
WeatherVisuals.VignetteIntensity,
PPAlpha
);
}
This is the key part of the system.
Time says:
It is night, so the world should be dark.
Weather says:
There is heavy fog, so the world should be foggier and more muted.
Climate says:
I will combine both and apply the final result.
Climate Blend Settings
The ClimateManager uses blend settings to control how much weather affects the time baseline.
USTRUCT(BlueprintType)
struct FRaptureClimateBlendSettings
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Climate|Blending", meta=(ClampMin="0.0", ClampMax="1.0"))
float WeatherSunInfluence = 0.35f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Climate|Blending", meta=(ClampMin="0.0", ClampMax="1.0"))
float WeatherSkyLightInfluence = 0.45f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Climate|Blending", meta=(ClampMin="0.0", ClampMax="1.0"))
float WeatherFogInfluence = 0.85f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Climate|Blending", meta=(ClampMin="0.0", ClampMax="1.0"))
float WeatherPostProcessInfluence = 0.65f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Climate|Blending")
bool bKeepMoonTimeDriven = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Climate|Blending")
bool bKeepCelestialRotationTimeDriven = true;
};
This gives a lot of control without needing custom logic for every scenario.
For example, a light rain weather state might use a small influence:
WeatherSunInfluence = 0.2
WeatherFogInfluence = 0.35
A heavy storm might use stronger influence:
WeatherSunInfluence = 0.65
WeatherSkyLightInfluence = 0.75
WeatherFogInfluence = 0.9
An eclipse style weather state could use almost full influence:
WeatherSunInfluence = 1.0
WeatherSkyLightInfluence = 1.0
WeatherFogInfluence = 1.0
This lets the system scale from normal day/night cycles to supernatural events.
Applying The Final Snapshot To The World
After the ClimateManager builds the final snapshot, it applies the values to world actors.
void ARaptureClimateManager::ApplySnapshotToWorld(const FRaptureClimateVisualSnapshot& Snapshot)
{
if (SunLightActor)
{
if (UDirectionalLightComponent* SunComp =
Cast<UDirectionalLightComponent>(SunLightActor->GetLightComponent()))
{
SunComp->SetIntensity(Snapshot.SunIntensity);
SunComp->SetLightColor(Snapshot.SunColor);
if (BlendSettings.bKeepCelestialRotationTimeDriven)
{
SunLightActor->SetActorRotation(Snapshot.SunRotation);
}
}
}
if (SkyLightActor)
{
if (USkyLightComponent* SkyComp =
Cast<USkyLightComponent>(SkyLightActor->GetLightComponent()))
{
SkyComp->SetIntensity(Snapshot.SkyLightIntensity);
SkyComp->SetLightColor(Snapshot.SkyLightColor);
}
}
if (HeightFogActor)
{
if (UExponentialHeightFogComponent* FogComp = HeightFogActor->GetComponent())
{
FogComp->SetFogDensity(Snapshot.FogDensity);
FogComp->SetFogInscatteringColor(Snapshot.FogColor);
}
}
}
This is the only place shared climate visuals should be applied.
That rule is what keeps the system clean.
Event Driven Updates
The ClimateManager can update on tick, on events, or both.
UENUM(BlueprintType)
enum class ERaptureClimateUpdateMode : uint8
{
TickOnly,
EventDriven,
TickAndEventDriven
};
During development, I like using:
TickAndEventDriven
This makes the system responsive while still updating at a predictable interval.
The ClimateManager binds to the TimeManager and WeatherManager:
TimeManager->OnTimeVisualSnapshotChanged.AddDynamic(
this,
&ARaptureClimateManager::HandleTimeVisualSnapshotChanged
);
WeatherManager->OnWeatherClimateStateUpdated.AddDynamic(
this,
&ARaptureClimateManager::HandleWeatherClimateStateUpdated
);
WeatherManager->OnLightningFlashRequested.AddDynamic(
this,
&ARaptureClimateManager::HandleLightningFlashRequested
);
This means:
When time changes, climate updates.
When weather changes, climate updates.
When weather transitions, climate updates.
When lightning is requested, climate applies the flash.
The three systems are separate, but they are still connected.
Debugging
Each system has its own debug CVar.
pr.DebugTimeSystem 1
pr.DebugWeatherSystem 1
pr.DebugClimateSystem 1
The debug overlays make it much easier to see what is happening at runtime.
The TimeManager overlay shows:
- Current day
- Current time
- Current time state
- Current time tag
- Current time scale
- Current visual snapshot values
The WeatherManager overlay shows:
- Current weather
- Climate weather state
- Transition alpha
- Wind source
- Active VFX
- Whether shared visual writes are disabled
The ClimateManager overlay shows:
- Time manager reference
- Weather manager reference
- Current final sun intensity
- Current final skylight intensity
- Current final fog density
- Current final vignette
- Lightning state
- Target world actors
This is important because climate systems can get confusing quickly. Having debug overlays for each layer makes it much easier to tell where a problem is coming from.
If the time state is wrong, check TimeManager.
If the active weather is wrong, check WeatherManager.
If the final world visuals are wrong, check ClimateManager.
Example Runtime Flow
A normal update looks like this:
1. TimeManager advances time.
2. TimeManager updates its current time state.
3. TimeManager rebuilds FRaptureTimeVisualSnapshot.
4. TimeManager broadcasts OnTimeVisualSnapshotChanged.
5. ClimateManager receives the event.
6. ClimateManager asks WeatherManager for GetWeatherStateForClimate().
7. ClimateManager blends time + weather.
8. ClimateManager applies the final snapshot to the world.
A weather transition looks like this:
1. WeatherManager starts transitioning from Clear to HeavyStorm.
2. WeatherManager blends between both weather states over time.
3. WeatherManager broadcasts OnWeatherClimateStateUpdated.
4. ClimateManager receives the blended weather state.
5. ClimateManager blends the weather influence into the current time baseline.
6. The world gradually becomes darker, foggier, and stormier.
A lightning strike looks like this:
1. WeatherManager thunder timer fires.
2. WeatherManager plays thunder audio.
3. WeatherManager broadcasts OnLightningFlashRequested.
4. ClimateManager receives the lightning request.
5. ClimateManager temporarily boosts sun intensity.
6. ClimateManager clears the flash after the duration expires.
This makes the full system feel connected while keeping responsibilities separated.
Why This Architecture Is Better
The biggest improvement is ownership.
Before, both time and weather could write to the same world actors.
Now, each system has a clear job:
TimeManager
- Knows what time it is.
- Knows what the baseline world should look like.
- Does not touch world actors.
WeatherManager
- Knows what weather is active.
- Handles wind, VFX, thunder, and weather transitions.
- Does not touch shared world visuals.
ClimateManager
- Combines time and weather.
- Applies final visuals.
- Owns the shared world actors.
This makes the system easier to reason about, easier to debug, and much easier to expand.
If I want to add a new time profile, I create a new time data asset.
If I want to add a new weather state, I add it to the weather data asset.
If I want to change how weather affects the world, I tune the ClimateManager blend settings.
Nothing needs to fight over control.
Final Thoughts
This system started as a basic day/night and weather setup, but it turned into a much cleaner climate architecture.
The biggest lesson from this was that visual ownership matters a lot. It is tempting to let every system apply its own changes directly, especially early in development. But once multiple systems start affecting the same actors, it becomes very easy to create bugs that are hard to track down.
By separating time, weather, and climate into their own responsibilities, the system is now much more scalable.
The TimeManager controls time.
The WeatherManager controls weather.
The ClimateManager controls the final look of the world.
That separation is what makes the whole system easier to expand. If I want a new biome, I can create a new time data asset. If I want a new storm, I can create a new weather state. If I want weather to have more or less control over the final scene, I can tune the ClimateManager blend settings.
This also makes debugging much more straightforward. Instead of asking, “Why does the lighting look wrong?”, I can break the problem down into smaller questions.
Is the TimeManager reporting the correct time state?
Is the WeatherManager reporting the correct weather state?
Is the ClimateManager blending and applying the final values correctly?
That is a much better workflow than having multiple systems overwrite each other without a clear owner.
Long term, this setup gives me a strong foundation for more advanced features too. I can add biome specific climate profiles, scripted eclipse sequences, raid night overrides, indoor climate profiles, dynamic lightning events, or gameplay reactions based on time and weather tags without needing to rebuild the entire system.
For a survival horror game like Project Rapture, atmosphere is a huge part of the experience. The player should feel the world changing around them. A safe homestead morning should not feel the same as a late night storm during a raid. A forest extraction zone should not feel the same as an eclipse event. This system gives me a scalable way to support those differences while keeping the code organized.
The end result is a system where time, weather, and climate all work together instead of competing for control.
Time creates the baseline.
Weather adds pressure and variation.
Climate applies the final atmosphere.
That structure gives Project Rapture a much stronger foundation for building a world that feels dynamic, readable, and intentionally unsettling.