Fog of War in Unreal Engine: The Fog Manager and Post-Process Setup

How the Fog Manager holds top-down fog of war together: an unbound post-process volume, a dynamic instance of the M_Main_Fog_Volume material driven from C++, and the fixed parameter contract it fills every tick.

This is step three of the Top-Down Fog of War in Unreal Engine guide. The introduction and the actors and components laid out the system; here we build the actor that holds it together. By the end you will have the foundation: an unbound post-process volume that darkens the whole level, a Fog Manager pushing live world data into a dynamic instance of M_Main_Fog_Volume, and the parameter contract the rest of the masks read.

The Fog Manager actor

The actor that owns the effect is AFogManager. Its Blueprint, BP_FogManager, is thin; the real work is in the C++ parent, which creates the post-process volume, builds a dynamic material instance, and pushes world data into it every tick.

BP_FogManager actor selected in the Unreal Engine level outliner

Here is the full BP_FogManager graph to inspect node by node:

Constructor: an unbound post-process volume

In the constructor we create a UPostProcessComponent and set bUnbound = true, so the volume affects the entire world rather than only the space inside its bounds. That one flag is what lets the fog cover the whole map from a single actor. We also tick at a fixed 60 Hz and mark the actor relevant only to its owner, since the fog is local.

AFogManager::AFogManager()
{
    PrimaryActorTick.bCanEverTick = true;
    PrimaryActorTick.TickInterval = 1 / 60.f;
    // The fog is purely visual and local, so each client runs its own manager.
    bOnlyRelevantToOwner = true;

    FogPostProcessComponent = CreateDefaultSubobject<UPostProcessComponent>(TEXT("FogPostProcess"));
    FogPostProcessComponent->bEnabled = true;
    FogPostProcessComponent->bUnbound = true; // cover the whole world, not just the bounds
}

The AFogManager C++ constructor creating a post-process volume component with bUnbound set to true

BeginPlay: a dynamic material instance

A plain material cannot receive live values, so in BeginPlay we make a UMaterialInstanceDynamic from M_Main_Fog_Volume, push the values that never change during play (map size, whether the world origin is centered, the visibility radius, and the baked floor texture), and add the instance to the post-process volume as a weighted blendable.

void AFogManager::BeginPlay()
{
    Super::BeginPlay();
    if (!FogMaterial) return;

    FoWMaterialInstance = UMaterialInstanceDynamic::Create(FogMaterial, this);
    FoWMaterialInstance->SetScalarParameterValue("MapSize", MapSize);                         // e.g. 3000
    FoWMaterialInstance->SetScalarParameterValue("IsMapCentered", bIsMapCentered);            // 0 or 1
    FoWMaterialInstance->SetScalarParameterValue("CharacterVisibilityRadius", CharacterVisibilityRadius); // e.g. 400
    if (FloorMapTexture)
        FoWMaterialInstance->SetTextureParameterValue("FloorMap", FloorMapTexture);           // baked walkable mask

    FogPostProcessComponent->Settings.WeightedBlendables.Array.Empty();
    FogPostProcessComponent->AddOrUpdateBlendable(FoWMaterialInstance);
}

BeginPlay creating a dynamic material instance and applying it to the post-process volume

Tick: feeding the material every frame

From Tick, the manager refreshes the live data each frame. There are three jobs, each detailed in its own step:

The manager is, in other words, a thin pump: read the local pawn, write its world into the material’s parameters, repeat.

The parameter contract

This is the most useful thing to copy, because the material is built entirely around these names. Expose exactly these parameters on M_Main_Fog_Volume and every step that follows will line up:

ParameterTypeSet inCarries
MapSizeScalarBeginPlayworld size in cm, used to normalize everything to UV
IsMapCenteredScalarBeginPlay1 if the world origin is centered (apply a +0.5 UV shift)
CharacterVisibilityRadiusScalarBeginPlayspotlight radius in world units
FloorMapTextureBeginPlaythe baked black-and-white walkable mask
CharacterUVLocationVectorTickplayer position in 0..1 UV
CharacterForwardDirectionVectorTickplayer facing (XY) for the FOV cone
CharacterFOVCosHalfAngleScalarTickcos(half FOV); -1 means full 360 vision

What’s next

You now have the skeleton: an unbound post-process volume, a Fog Manager driving a dynamic instance of M_Main_Fog_Volume, and the parameter contract the masks read. Next, see how the material itself turns that mask into darkness and light: how M_Main_Fog_Volume controls the shadows.

If you would rather drop this into your project ready-made, the Multiplayer Fog of War plugin packages all of this up, replicated and performance-minded, for multiplayer and single-player top-down games.

Frequently asked questions

Why does the post-process volume need bUnbound?
Without it, the fog only affects the camera while it is inside the volume's bounds. With bUnbound set to true, a single volume covers the entire level, which is what you want for a global fog.
Why a dynamic material instance instead of the material directly?
A plain material cannot receive live values. A UMaterialInstanceDynamic lets the Fog Manager push the player's position, visibility radius, and facing into the material every frame.
What is the parameter contract the Fog Manager fills?
It is the fixed set of material parameters every mask reads: MapSize, IsMapCentered, CharacterVisibilityRadius, FloorMap, CharacterUVLocation, CharacterForwardDirection, and CharacterFOVCosHalfAngle. The manager sets the static ones in BeginPlay and the per-frame ones in Tick.