These light fixture actor types were created to support detailed light fixtures with standarized behaviour across larger scenes in Unreal Engine. There are a set of C++ framework classes providing shared functionality to support almost any type of light with an illuminated bulb or fixture. There are also several Blueprint implementations of various light fixtures intended for placement within levels.

Having a set of predefined (but easily customizable) setups for lighting are a huge help with regards to management of consistency and complexity. Default values for properties like color or spot cone angle can be defined on a per-class basis, so if it becomes necessary to update the default color or spread of a certain light type, that change can easily propagate to every instance which was already placed in a level. Settings can be further overridden on a per-instance basis as necessary, depending on the specific scene and placement.

Beyond this, encapsulating all of the various elements together within a custom Actor class (and subsequent Blueprint classes) creates easier workflows for designers and artists. Adjusting the light's color or intensity is now accomplished by changing properties which affect the state of multiple components. For example, changing the light's color property affects both the color of the light source and a MaterialInstanceDynamic associated with the light instance, at the same time.


The LightFixtureBase class provides common components and behaviour for all light fixture instances:

  • Provides a visible "bulb" (StaticMeshComponent with emissive material)
    • Meshes were initially created with a separate bulb mesh, or use a second material ID for the emissive surface.
  • Provides a means to manage properties of a LightComponent - color, radius, attenuation, shadows, mobility.
  • Implements the project's ISwitchable interface, allowing dynamic lights to respond to TurnOn/TurnOff requests.
  • Optional per-instance MaterialInstanceDynamic creation - customizable color, emissive intensity, texture, and flickering phase/time control
  • Optional flickering behaviour, synchronized between the cast source (lightfunction material) and material instance

Two child classes derive from LightFixtureBase, LightFixtureSpot and LightFixturePoint. These specialized classes fill in the empty LightComponent reference defined in the parent class with a SpotLightComponent or PointLightComponent, respectively, and serve as the base for the Blueprint light fixtures. The source for the LightFixtureSpot class in its entirety is reproduced along with the header/constructor from the LightFixtureBase class below, to reinforce that it mostly exists as an intermediate class supporting the further derived Blueprint classes.

// .h
UCLASS(Abstract, BlueprintType, Category = "LightFixture", meta = (PrioritizeCategories = "LightFixture"))
class ALightFixtureBase : public AActor, public ISwitchable
{
    // just an excerpt to illustrate that ALightFixtureBase is UCLASS(Abstract) and implements ISwitchable. 

// .cpp
ALightFixtureBase::ALightFixtureBase() : Super()
{
    PrimaryActorTick.bCanEverTick = false;
    PrimaryActorTick.bStartWithTickEnabled = false;

    RootComp = CreateDefaultSubobject<USceneComponent>(TEXT("RootComponent"));
    RootComp->SetMobility(EComponentMobility::Static);
    SetRootComponent(RootComp);

    LightAttachPoint = CreateDefaultSubobject<USceneComponent>(TEXT("LightAttachPoint"));
    LightAttachPoint->SetupAttachment(RootComp);
    LightAttachPoint->SetComponentTickEnabled(false);
    LightAttachPoint->SetMobility(RootComp->Mobility);
    LightAttachPoint->SetRelativeRotation(FRotator(-90.0f, 180.0f, 180.0f));
    LightAttachPoint->SetRelativeLocation(FVector(0.0f, 0.0f, -30.0f));

    Bulb = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Bulb"));
    Bulb->SetupAttachment(RootComp);
    Bulb->SetComponentTickEnabled(false);
    Bulb->SetMobility(RootComp->Mobility);

    static ConstructorHelpers::FObjectFinder<UMaterialInterface> lightfuncmat(TEXT("/Game/MYPROJECT/Materials/Base/M_BaseLightFunction.M_BaseLightFunction"));
    Lightfunction = lightfuncmat.Object;

    Flags =
        (int32)ELightFixtureFlags::ShadowCaster |
        (int32)ELightFixtureFlags::CreateMaterialInstances |
        (int32)ELightFixtureFlags::LightAffectsWorld |
        (int32)ELightFixtureFlags::FillAffectsWorld |
        (int32)ELightFixtureFlags::HasSound |
        (int32)ELightFixtureFlags::LoopingSound |
        (int32)ELightFixtureFlags::OnOffSound ;

}
// .h
UCLASS(Blueprintable, BlueprintType, Category = "LightFixture")
class ALightFixtureSpot : public ALightFixtureBase
{
    GENERATED_BODY()

public:
    ALightFixtureSpot();
};

// .cpp
ALightFixtureSpot::ALightFixtureSpot() : Super() 
{
    LightComp = CreateDefaultSubobject<USpotLightComponent>(TEXT("SpotLight"));
    LightComp->SetupAttachment(LightAttachPoint);

    LightComp->SetCastShadows(false);
    LightComp->SetIntensityUnits(ELightUnits::Unitless);
    LightComp->SetAttenuationRadius(750.0f);

    FillComp = CreateDefaultSubobject<UPointLightComponent>(TEXT("FillLight"));
    FillComp->SetupAttachment(LightAttachPoint);
    FillComp->SetCastShadows(false);
    FillComp->SetIntensityUnits(ELightUnits::Unitless);
    FillComp->SetIntensity(100.0f);
    FillComp->SetAttenuationRadius(100.0f);

    // properties to non-destructively disable lights
    LightComp->bAffectsWorld = 
        Flags & (int32)ELightFixtureFlags::LightAffectsWorld;
    FillComp->bAffectsWorld = 
        Flags & (int32)ELightFixtureFlags::FillAffectsWorld;
}

These classes also add an extra, non-shadow-casting 'fill' light component which illuminates the interior of the fixture. This was useful for some cases like fluorescent ceiling lights - the fill light can illuminate back towards the cast source so that the actual shadow-casting light can remain a spotlight for performance reasons.

The color and intensity of the fill effect are automatically derived from the color and intensity of the main light source, but some adjustment is necessary to prevent the fill on dim light sources from looking overblown.

double ALightFixtureBase::GetFillCompIntensity()
{
    return (Intensity / (Intensity > 2000 ? 40 : 10));
}

The actual light fixtures which get placed into the scene are further derived Blueprint classes as mentioned earlier. From these examples pictured, the work light, candle, wall light, and warehouse lamp were created by other artists. These classes specify/override the default properties in respect to color, angle, attenuation, mesh, and so on. These classes also implement custom behaviours to add extra parts, or select from a series of mesh variations stored as soft references. The example shown below does exactly this while (optionally) adding an extra StaticMeshComponent for a glass cover. Extra mesh components could have also been added for the brackets and cage parts, but it saves a few draw calls to just load a merged 'prefab' mesh instead.


Code Excerpts


I've updated the excerpts here after working on an implementation of these actors for Unreal Engine 5.

Flags

  • Behaviour characteristics of the light fixture are stored as a series of bitflags.
  • I personally find the (marginal) tradeoff in complexity and legibility is worth it for an organizational benefit - as bitflag properties are exposed to designers in-engine as a single dropdown list.
  • The property with Bitmask meta didn't render all flags (specifically those with a value > 32) until UseEnumValuesAsMaskValuesInEditor was set.
UENUM(Meta = (BitFlags, UseEnumValuesAsMaskValuesInEditor = "true"))
enum class ELightFixtureFlags
{
    NONE = 0 UMETA(Hidden),
    ShadowCaster = 1,
    LightAffectsWorld = 2,
    FillAffectsWorld = 4,
    InitiallyOff = 8,
    Flickering = 16,
    HasSound = 32,
    LoopingSound = 64,
    OnOffSound = 128,
    CreateMaterialInstances = 256
};
ENUM_CLASS_FLAGS(ELightFixtureFlags);

// property exists inside ALightFixtureBase class
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "LightFixture", meta = (Bitmask, BitmaskEnum = "ELightFixtureFlags"))
int32 Flags;

Initialization

  • Called during OnConstruction to establish initial state.
  • In the base class, LightComp is an (empty) ULocalLightComponent.
  • This superclass function handles the setup of the specialized components created in the subclasses. For example, the LightFixtureBaseSpot class assigns a USpotLightComponent to LightComp before this initialization function is executed.
void ALightFixtureBase::Initialize()
{
    if (IsValid(LightComp)) 
    {
        // set LightComp properties
        LightComp->SetMobility(LightMobilityType);
        LightComp->SetCastShadows(Flags & (int32)ELightFixtureFlags::ShadowCaster);
        LightComp->SetIntensity(Intensity);
        LightComp->SetLightColor(LightColor);
        LightComp->bAffectsWorld = Flags & (int32)ELightFixtureFlags::LightAffectsWorld;

        // set FillComp properties
        if (IsValid(FillComp)) 
        {
            FillComp->SetMobility(LightMobilityType);
            FillComp->SetIntensity(GetFillCompIntensity());
            FillComp->SetLightColor(LightColor);
            FillComp->bAffectsWorld = Flags & ELightFixtureFlags::FillAffectsWorld;
        }

        // Create emissive + flicker MIDs
        SetupMIDs();

        // Apply lightfunctions to light sources
        SetLightfunctionInstOrMID(LightfunctionMID, Lightfunction, LightComp);
        if (IsValid(FillComp)) 
        {
            SetLightfunctionInstOrMID(LightfunctionMID, Lightfunction, FillComp);
        }

        // set initial light state for movable/togglable types.
        (Flags & ELightFixtureFlags::InitiallyOff) && LightMobilityType == EComponentMobility::Movable 
            ? LightTurnOff() : LightTurnOn(true);

    } 
    else 
    {
        // has no lightcomp, so is always off
        SetBulbMaterial(*OffMaterial);
        GetWorld()->GetTimerManager().ClearTimer(FlickerTimerHandle);
    }
}

Setup and Assign Materials

  • Creates dynamic material instances for the individual fixture, if the bCreateMaterialInstances property is set.
  • If not, constant material instances will load from class default properties.
  • Even in this case, dynamic instances are still created for flickering light sources.
  • Convenience functions were written to select either the dynamic instance or the specified constant material.
void ALightFixtureBase::SetupMIDs()
{
    if (Flags & (int32)ELightFixtureFlags::CreateMaterialInstances)
    {
        if (!IsValid(OnMID) && IsValid(OnMaterial))
        {
            // no OnMID, but valid OnMat. create OnMID
            OnMID = UMaterialInstanceDynamic::Create(OnMaterial, this);
            OnMID->SetScalarParameterValue(FName(TEXT("EmissiveMultiplier")), EmissiveMultiplier);
            OnMID->SetVectorParameterValue(FName(TEXT("EmissiveTint")), LightColor * 1.3f);
            OnMID->SetVectorParameterValue(FName(TEXT("BaseColorTint")), LightColor);
        }

        if (!IsValid(LightfunctionMID) && IsValid(Lightfunction))
        {   
            // no LightfunctionMID, but valid Lightfunction. create the LightfunctionMID
            LightfunctionMID = UFlickerBlueprintFunctions::SetupFlickeringLight(
                OnMID,
                LightColor,
                Lightfunction,
                FlickerProperties,
                EmissiveMultiplier,
                this );

            LightfunctionMID->SetScalarParameterValue(FName(TEXT("Flicker_Control")), 0.0f);
        }
    } 
    else 
    {
        OnMID = nullptr;
        LightfunctionMID = nullptr;
    }
}

void ALightFixtureBase::SetBulbInstOrMID(
    UMaterialInstanceDynamic* MID, UMaterialInterface* Inst, bool bSetEmissive, double EmissiveAmount)
{
    const bool createMatInstances = 
        Flags & (int32)ELightFixtureFlags::CreateMaterialInstances;

    if (createMatInstances && IsValid(MID))
    {
        SetBulbMaterial(*MID);
        if (bSetEmissive) 
        {
            MID->SetScalarParameterValue(FName(TEXT("EmissiveMultiplier")), EmissiveAmount);
        }
    }
    else if (!createMatInstances && IsValid(Inst))
    {
        SetBulbMaterial(*Inst);
    }
}

void ALightFixtureBase::SetLightfunctionInstOrMID(
    UMaterialInstanceDynamic* MID, UMaterialInterface* Inst, ULocalLightComponent* Comp)
{
    // apply generated MID if material instances created. otherwise, assign a constant MI
    if (Flags & (int32)ELightFixtureFlags::CreateMaterialInstances)
    {
        Comp->SetLightFunctionMaterial(MID);
    } 
    else 
    {
        Comp->SetLightFunctionMaterial(Inst);
    }
}

TurnOn/TurnOff

  • Change the light's state - affecting cast light, fill light, and emissive material.
  • Used at init-time for all light mobility types, but only for movable (dynamic) types during gameplay.
void ALightFixtureBase::LightTurnOn(bool bInit)
{
    if (AudioComp && IsOff()) // don't play for already-on lights
    {
        PlayAudio(TEXT("LightSource.TurnOn"));

        // stop audio with 5s delay, if not looping/flicker
        // this prevents the empty source from hanging around, but it can restart if needed.
        const bool loopSnd = Flags & (int32)ELightFixtureFlags::LoopingSound;
        if (!loopSnd && !IsFlickering())
        {
            StopAudio(5.0f);
        }
    }

    bLightOn = true;

    IsFlickering() ? StartFlickering(bInit) : StopFlickering();

    SetLightComponentVisibility(true);
}

void ALightFixtureBase::LightTurnOff()
{
    if (AudioComp && IsOn())
    {
        if (!AudioComp->IsPlaying())
        {
            PlayAudio(TEXT("LightSource.TurnOff"));
        }
        else
        {
            AudioComp->SetTriggerParameter(FName(TEXT("LightSource.TurnOff")));
        }
        StopAudio(5.0f);
    }

    bLightOn = false;

    // unassigns flicker mats, but also sets materials back to on/off states
    StopFlickering();

    SetLightComponentVisibility(false);
}

Switchable Interface

  • Base class implements functions from project's ISwitchable interface to respond to arbitrary on/off requests.
// .h
virtual void TurnOn_Implementation() override;
virtual void TurnOff_Implementation() override;
virtual void Toggle_Implementation() override;

// .cpp
void ALightFixtureBase::TurnOn_Implementation()
{
    if(LightMobilityType == EComponentMobility::Movable) 
    {
        LightTurnOn();
    }
}

void ALightFixtureBase::TurnOff_Implementation()
{
    if (LightMobilityType == EComponentMobility::Movable) 
    {
        LightTurnOff();
    }
}

Flickering

  • The default behaviour is for lights to either flicker or not to flicker, but fixtures can be dynamically scripted to start and stop flickering at will.
  • The effect is accomplished by setting a dynamic material instance on the bulb mesh and a lightfunction material on the cast source, then synchronizing material properties between the two so they appear to be animated in phase.
    • To avoid the requirement for a material swap, the base material was later refactored to account for flickering behaviour as an integral part of the 'emissive' output. This only applies when material instances are created and associated with the actor (as oppposed to referencing a MaterialInstanceConstant).
  • A timer runs to poll the flickering state at 1/10s duration. Previously it was handled in the actor's tick function, but this is now reserved for any other actor or fixture-specific behaviour.
    • The timer is only used to handle synchronization of the flickering audio with the material instance. It's further gated behind a flag.
  • FlickerProperties is a struct wrapping the flickering time/offset/phase values, used during the timer update to calculate the current intensity.
  • There is a corresponding metasound source (UE5) for flickering noise, which can be specified per-class. It is resolved from a soft reference and has some state which is initialized on BeginPlay.
    • TODO: In the future, I will update this space with more information about scripting the metasound source and managing its stateful behaviour. It handles ambient looping, flickering, and on/off clicking while also managing changes of state (on/off, flicker on/off)

void ALightFixtureBase::BeginPlay()
{
    //  this is an excerpt of BeginPlay with only the code relevant to flickering/audio behaviour.

    // set initial on state
    Flags & (int32)ELightFixtureFlags::InitiallyOff && LightMobilityType == EComponentMobility::Movable 
        ? LightTurnOff() : LightTurnOn(false);

    if (Flags & (int32)ELightFixtureFlags::HasSound)
    {
        // try resolve sound for audiocomp - 
        // if it can't be resolved, don't bother creating an audiocomp for an empty sound.
        USoundBase* snd;
        if (Sound.Get())
        {
            snd = Sound.Get();
        }
        else
        {
            snd = Sound.LoadSynchronous();
        }

        if (snd)
        {
            UActorComponent* comp = AddComponentByClass(UAudioComponent::StaticClass(), false, FTransform(), false);
            AudioComp = Cast<UAudioComponent>(comp);

            if (AudioComp)
            {
                AudioComp->AttachToComponent(LightAttachPoint, FAttachmentTransformRules(EAttachmentRule::SnapToTarget, true));
                AudioComp->SetSound(snd);

                // initially on AND  looping or flickering? play the sound
                const bool initOff = Flags & (int32)ELightFixtureFlags::InitiallyOff;
                const bool loopSnd = Flags & (int32)ELightFixtureFlags::LoopingSound;
                if ( !initOff && (IsFlickering() || loopSnd) )
                {
                    PlayAudio(FName(NAME_None));
                    if (IsFlickering()) // has sound and flickering? set timer handle
                    {
                        GetWorld()->GetTimerManager().SetTimer(FlickerTimerHandle, this, 
                            &ALightFixtureBase::FlickerUpdate, 0.1f, true);
                    }
                }
            }
        }
    }
}

void ALightFixtureBase::SetFlickering(bool bNewFlicker)
{
    if (bNewFlicker == IsFlickering())
    {
        return; // same as current flickering flag. so do nothing. 
    }
    else
    {
        if (IsOn()) // light is on, so update the visual state
        {
            bNewFlicker ? StartFlickering() : StopFlickering();
        }

        // in either case, update the new flicker state.
        bNewFlicker ? 
            Flags |= (int32)ELightFixtureFlags::Flickering : 
            Flags &~ (int32)ELightFixtureFlags::Flickering ;
    }    
}

void ALightFixtureBase::StartFlickering(bool bInit)
{
    if (OnMID)
    {
        OnMID->SetScalarParameterValue(FName(TEXT("Flicker_Control")), 1.0f);
    }

    if (LightfunctionMID)
    {
        LightfunctionMID->SetScalarParameterValue(FName(TEXT("Flicker_Control")), 1.0f);
    }

    SetBulbInstOrMID(OnMID, FlickerMaterial, true, EmissiveMultiplier);

    // only affect lightfunction for non-static types
    if (LightMobilityType != EComponentMobility::Static) 
    {
        if (IsValid(LightComp)) 
        {
            SetLightfunctionInstOrMID(LightfunctionMID, Lightfunction, LightComp);
        }

        if (IsValid(FillComp)) 
        {
            SetLightfunctionInstOrMID(LightfunctionMID, Lightfunction, FillComp);
        }
    }

    FTimerManager& tm = GetWorld()->GetTimerManager();
    if (Flags & (int32)ELightFixtureFlags::HasSound && !bInit)
    {
        tm.SetTimer(FlickerTimerHandle, this, &ALightFixtureBase::FlickerUpdate, 0.1f, true);
        if (AudioComp && !AudioComp->IsPlaying())
        {
            // play the sound source if it wasn't playing already.
            // the sound persists playing and responds to flickerhit trigger while the light is turned on.
            PlayAudio(TEXT("LightSource.FlickerHit")); 
        }
    } 
    else
    {
        tm.ClearTimer(FlickerTimerHandle);
    }
}

void ALightFixtureBase::StopFlickering()
{
    if (OnMID)
    {
        OnMID->SetScalarParameterValue(FName(TEXT("Flicker_Control")), 0.0f);
    }

    if (LightfunctionMID)
    {
        LightfunctionMID->SetScalarParameterValue(FName(TEXT("Flicker_Control")), 0.0f);
    }

    // swap materials to on/off state
    IsOn() ? SetBulbInstOrMID(OnMID, OnMaterial, true, EmissiveMultiplier) : 
        SetBulbInstOrMID(OnMID, OffMaterial, true, 0.0f);

    // clear lightfunction mats
    if (IsValid(LightComp)) 
    {
        LightComp->SetLightFunctionMaterial(nullptr); 
    }
    if (IsValid(FillComp)) 
    {
        FillComp->SetLightFunctionMaterial(nullptr); 
    }

    // valid audiocomp, but sound doesn't loop. 
    // so stop playback until the next StartFlickering, or a TurnOn/Off event.
    const bool loopSnd = Flags & (int32)ELightFixtureFlags::LoopingSound;
    if (!loopSnd && IsValid(AudioComp))
    {
        StopAudio(5.f);
    }

    // clear out the timer handle
    GetWorld()->GetTimerManager().ClearTimer(FlickerTimerHandle);
}

void ALightFixtureBase::FlickerUpdate()
{
    if (UFlickerBlueprintFunctions::CalculateFlickerValue(
        this, FlickerProperties) < 0.75f)
    {
        // gates the flicker sound playback
        FTimerManager& tm = GetWorld()->GetTimerManager();
        if (!tm.IsTimerActive(FlickerResetHandle))
        {
            if (AudioComp)
            {
                AudioComp->SetTriggerParameter(TEXT("LightSource.FlickerHit"));
            }
            tm.SetTimer(FlickerResetHandle, 0.5f, false);
        }
    }
}