I created these light fixture prefabs (actor classes) to support detailed, standardized light fixtures across larger Unreal scenes. There are a set of C++ framework classes providing shared behaviour 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 was a huge help - not only with regards to consistency, but also managing 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 requirements of the scene or lighting artist.

Beyond this, coupling the different components together in a Blueprint setup creates easier workflows for designers and artists. Adjusting a light's color or intensity is now accomplished by changing individual properties which affect the state of multiple components. For example, changing the light's color can affect both the color of the light source and the associated material instance at the same time.


The LightFixtureBase class provides common components and behaviour for all fixtures:

  • Provides a visible "bulb" (StaticMeshComponent with emissive material)
    • Meshes were 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, LightFixtureBaseSpot and LightFixtureBasePoint. 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 LightFixtureBaseSpot class is reproduced below to reinforce that it mostly exists as an intermediate class supporting the further derived Blueprint classes.

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

public:
    ALightFixtureBaseSpot();
};

// .cpp
ALightFixtureBaseSpot::ALightFixtureBaseSpot() {

    // ALightFixtureBase constructor handles setup for the LightAttachPoint before this
    // a further initialization will occur OnConstruction

    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 = bLightAffectsWorld;
    FillComp->bAffectsWorld = bFillAffectsWorld;
}

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. Some adjustment is necessary to prevent the fill on dim light sources from looking overblown, this was just hardcoded into the base class.

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

The actual light fixtures which get placed into the scene are, as mentioned earlier, further derived Blueprint classes (created by various artists) which specify their own default properties in respect to color, angle, attenuation, mesh, and so on. These classes can 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 - and, optionally, adds 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


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()
{
    PrimaryActorTick.bCanEverTick = bFlickerSound;

    if (IsValid(LightComp)) {

        // set LightComp properties
        LightComp->SetMobility(LightMobilityType);
        LightComp->SetCastShadows(bCastShadow);
        LightComp->SetIntensity(Intensity);
        LightComp->SetLightColor(LightColor);
        LightComp->bAffectsWorld = bLightAffectsWorld;

        // set FillComp properties
        if (IsValid(FillComp)) {
            FillComp->SetMobility(LightMobilityType);
            FillComp->SetIntensity(GetFillCompIntensity());
            FillComp->SetLightColor(LightColor);
            FillComp->bAffectsWorld = bFillAffectsWorld;
        }

        // 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
        if (bInitiallyOff && LightMobilityType == EComponentMobility::Movable) {
            LightTurnOff();
        } else {
            LightTurnOn();
        }
    } else {
        // has no lightcomp, is always off
        SetBulbMaterial(OffMaterial);
    }
}

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 (bCreateMaterialInstances) {

        if (!IsValid(OnMID)) {
            if (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)) {
            if (IsValid(Lightfunction)) { // no LightfunctionMID, but valid Lightfunction. create LightfunctionMID
                LightfunctionMID = UMaterialInstanceDynamic::Create(Lightfunction, this);
            }
        }

    } else {

        OnMID = nullptr;
        FlickerMID = nullptr;
        LightfunctionMID = nullptr;
        FlickerLightfunctionMID = nullptr;

    }

    if (!IsValid(FlickerMID)) {
        if(IsValid(FlickerMaterial)) { // no FlickerMID, but valid FlickerMat. create FlickerMID
            FlickerMID = UMaterialInstanceDynamic::Create(FlickerMaterial, this);
        }
    }

    if (IsValid(FlickerMID) && IsValid(FlickerLightfunction)) {
        // setup flickering light and assign the resulting lightfunction MID
        FlickerLightfunctionMID = UFlickerBlueprintFunctions::SetupFlickeringLight(
            FlickerMID, LightColor, FlickerLightfunction, FlickerProperties, EmissiveMultiplier, this);
    }

    return;
}

void ALightFixtureBase::SetBulbInstOrMID(
    UMaterialInstanceDynamic* MID, UMaterialInterface* Inst, bool bSetEmissive, double EmissiveAmount)
{
    // apply generated MID if material instances created. otherwise, assign a constant MI
    if (bCreateMaterialInstances) {
        if (!IsValid(MID)) return; // needs valid MID to assign

        SetBulbMaterial(MID);
        if (bSetEmissive) {
            MID->SetScalarParameterValue(FName(TEXT("EmissiveMultiplier")), EmissiveAmount);
        }
    } else {
        if (!IsValid(Inst)) return; // needs valid Mat.instance to assign

        SetBulbMaterial(Inst);
    }
}

void ALightFixtureBase::SetLightfunctionInstOrMID(
    UMaterialInstanceDynamic* MID, UMaterialInterface* Inst, ULocalLightComponent* Comp)
{
    // apply generated MID if material instances created. otherwise, assign a constant MI
    if (bCreateMaterialInstances) {
        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.
  • Base class implements functions from project's ISwitchable interface to respond to on/off requests (the _Implementation functions).
void ALightFixtureBase::LightTurnOn()
{
    bLightOn = true;

    if (bFlickering) {
        // assign flicker mats
        StartFlickering();
    }
    else {
        // select to assign constant MI or previously generated MID
        SetBulbInstOrMID(OnMID, OnMaterial, true, EmissiveMultiplier);
    }

    // set end visibility of lightcomponents
    if (IsValid(LightComp)) {
        LightComp->SetVisibility(true);
    }

    if (IsValid(FillComp)) {
        FillComp->SetVisibility(true);
    }
}

void ALightFixtureBase::LightTurnOff()
{
    bLightOn = false;

    if (bFlickering) {
        // unassign flicker mats
        StopFlickering();
    }
    else {
        // off state, select to assign constant MI or MID
        // note, dynamic lights still use MID path, but set the emissive mult. to 0
        SetBulbInstOrMID(OnMID, OffMaterial, true, 0.0f);
    }

    // set end visibility of lightcomponents
    if (IsValid(LightComp)) {
        LightComp->SetVisibility(false);
    }

    if (IsValid(FillComp)) {
        FillComp->SetVisibility(false);
    }
}

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.
  • The actor tick is enabled for flickering light sources and disabled when flickering stops. It updates at a rate of 1/10sec instead of per-frame.
  • FlickerProperties is a struct wrapping the flickering time/offset/phase values, used during the tick update to calculate the current intensity.
  • There is a corresponding sound cue for a flickering noise, which can be specified per-class - it is resolved from a soft reference.
  • The sound plays when the flickering intensity reaches above a certain threshold. A timer is used to limit how often the sound can play, but a superior approach might be to assign a Sound Concurrency to the flicker cue, then adjust the RetriggerTime property of the concurrency asset.

void ALightFixtureBase::StartFlickering()
{
    SetBulbInstOrMID(FlickerMID, FlickerMaterial, true, EmissiveMultiplier);

    // only affect lightfunction for non-static types
    if (LightMobilityType != EComponentMobility::Static) {

        if (IsValid(LightComp)) {
            SetLightfunctionInstOrMID(FlickerLightfunctionMID, FlickerLightfunction, LightComp);
        }

        if (IsValid(FillComp)) {
            SetLightfunctionInstOrMID(FlickerLightfunctionMID, FlickerLightfunction, FillComp);
        }
    }

    if (bFlickerSound) {
        SetActorTickEnabled(true);
    }
}

void ALightFixtureBase::StopFlickering()
{
    // swap materials - if stopping from LightOn state, set on material
    // if stopping from !LightOn, then set off material or 0.0 emissive
    if (bLightOn) {
        SetBulbInstOrMID(OnMID, OnMaterial, true, EmissiveMultiplier);
    } else {
        SetBulbInstOrMID(OnMID, OffMaterial, true, 0.0f);
    }

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

    if (IsValid(FillComp)) {
        FillComp->SetLightFunctionMaterial(nullptr); 
    }

    // tick is only associated with flickersound. not flickering? don't tick. 
    SetActorTickEnabled(false);
}

void ALightFixtureBase::FlickerTimerHit()
{
    // resets gate for flicker sound
    bPlayingFlickerSound = false;
}

void ALightFixtureBase::ResolveFlickerSound()
{
    USoundBase* sndBase = FlickerSoundCue.Get();

    if(IsValid(sndBase)) {
        FlickerSoundBase = sndBase;
    }
}

void ALightFixtureBase::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (!bResolvedFlickerSound) {
        bResolvedFlickerSound = true;
        UAssetManager::GetStreamableManager().RequestAsyncLoad(FlickerSoundCue.ToSoftObjectPath(),
            FStreamableDelegate::CreateUObject(this, &ALightFixtureBase::ResolveFlickerSound));
    }

    if (UFlickerBlueprintFunctions::CalculateFlickerValue(this, FlickerProperties) < 0.8f) {
        // gate flicker sound playback
        if (!bPlayingFlickerSound) {
            bPlayingFlickerSound = true;

            UGameplayStatics::PlaySoundAtLocation(this, FlickerSoundBase, LightAttachPoint->GetComponentLocation());

            FTimerHandle flickerSoundTimer;
            GetWorldTimerManager().SetTimer(flickerSoundTimer, this, &ALightFixtureBase::FlickerTimerHit, FlickerSoundDelay, false);
        }
    }
}