Tutorial: Client sided dynamic lights (muzzle flash, flashlight...) Last edited 1 year ago2023-01-02 12:43:48 UTC

This tutorial explains how to manipulate dynamic lights on the client by making your own client sided flashlight.
Why? We already have a flashlight in Half-Life!
Sure, but have you wondered:
  • How the actual lighting is done?
  • How could you customize it (change the color, radius...)?
  • How could you use dynamic lights for other purposes like lighting the world whenever there is a muzzle flash?
  • Probably more.
This tutorial covers the answers to these questions.
Too many dynamic lights will kill the performance (Frames Per Seconds)!
While dynamic lights are useful for a various number of purposes, having too many of them can severely damage the performance of your mod.

If you are familiar with Half-Life level design, the map's lighting has to be compiled by the radiosity (RAD) compiler. Dynamic light basically "invalidate" the concerned lightmaps to calculate how the lighting would be rendered with the new light at runtime and this is an "expensive" operation even on today's hardware.

Use dynamic lights with caution!
This tutorial will not cover how to make an "extremely realistic flashlight" à la Source/Unity/Unreal Engine that requires you to "ditch" the GoldSrc OpenGL renderer and make your own (à la Arrangement/ArrangeMode, Cry of Fear, PARANOIA, Trinity or whatever).

How the current flashlight works

Historically, the flashlight in Half-Life was supposed to be a tool for level designers only before becoming an actual gameplay mechanic. If you have read the player code (player.cpp in the server project) in any "standard" Half-Life SDK, you might have seen this:
BOOL CBasePlayer::FlashlightIsOn()
{
    return FBitSet( pev->effects, EF_DIMLIGHT );
}

void CBasePlayer::FlashlightTurnOn()
{
    // Game rules check if flashlight is allowed here

    if ( (pev->weapons & (1 << WEAPON_SUIT)) )
    {
        // Flashlight turn on sound code here

        SetBits( pev->effects, EF_DIMLIGHT );

        // Client message and timing code here
    }
}


void CBasePlayer::FlashlightTurnOff()
{
    // Flashlight turn off sound code here

    ClearBits( pev->effects, EF_DIMLIGHT );

    // Client message and timing code here
}
The server basically set, check and clear the EF_DIMLIGHT bit in the effects variable inside the player's private entity variable (pev) structure.

The question is: where is the actual lighting code? The one that set the color, radius, draw the light and so on?

Using any decent IDE (Integrated Development Environment) like Visual Studio, we can use the "find in files" tool to search everything EF_DIMLIGHT related.

You will realize that besides the #define EF_DIMLIGHT 8 declaration in the common/const.h file and the usages in the code above, you won't find anything in the client/server projects.

So it's likely in the engine itself, but we sadly don't have access to that code. Remember that GoldSrc is a modified version of the Quake engine, so we can take a look at Quake's source code on GitHub and pray to find what we are after.

After searching for everything EF_DIMLIGHT in Quake's code, there is an interesting part in QW/client/cl_ents.c (link here):
void CL_LinkPacketEntities()
{
    // Local variables declaration here

    for ( pnum=0 ; pnum < pack->num_entities ; pnum++ ) // Iterate through all "packet entities"
    {
        s1 = &pack->entities[pnum];
        s2 = s1;    // FIXME: no interpolation right now

        // spawn light flashes, even ones coming from invisible objects
        if ( (s1->effects & (EF_BLUE | EF_RED)) == (EF_BLUE | EF_RED) )
            CL_NewDlight( s1->number, s1->origin[0], s1->origin[1], s1->origin[2], 200 + (rand() & 31), 0.1, 3 );
        else if ( s1->effects & EF_BLUE )
            CL_NewDlight( s1->number, s1->origin[0], s1->origin[1], s1->origin[2], 200 + (rand() & 31), 0.1, 1 );
        else if ( s1->effects & EF_RED )
            CL_NewDlight( s1->number, s1->origin[0], s1->origin[1], s1->origin[2], 200 + (rand() & 31), 0.1, 2 );
        else if ( s1->effects & EF_BRIGHTLIGHT )
            CL_NewDlight( s1->number, s1->origin[0], s1->origin[1], s1->origin[2] + 16, 400 + (rand() & 31), 0.1, 0 );
        else if ( s1->effects & EF_DIMLIGHT )
            CL_NewDlight( s1->number, s1->origin[0], s1->origin[1], s1->origin[2], 200 + (rand() & 31), 0.1, 0 );

        // Rest of the code like visibility checks, setup (model, body, skin...) and handling (origin, rotation, flags...) here
    }
}
Looks like a new client dynamic light is created for 0,1 second for all entities that have the EF_DIMLIGHT flag set which makes sense for now. We can also assume CL_LinkPacketEntities is called every frame. If we take a deeper look at what CL_NewDlight does, we can see this (link here):
void CL_NewDlight (int key, float x, float y, float z, float radius, float time, int type)
{
    dlight_t *dl;

    dl = CL_AllocDlight( key );
    dl->origin[0] = x;
    dl->origin[1] = y;
    dl->origin[2] = z;
    dl->radius = radius;
    dl->die = cl.time + time;
    if (type == 0) {
        dl->color[0] = 0.2;
        dl->color[1] = 0.1;
        dl->color[2] = 0.05;
        dl->color[3] = 0.7;
    } // Other types here
}
Good eyes will notice that the color and radius does not match Half-Life's flashlight. Remember that we are looking at Quake's code, not Half-Life's so there is going to be inaccuracies between the code of the two games.

Now we know how the current flashlight works but we can't modify it. Since we can use dlight_t on the client project, we're going to make a "workaround" to not use the engine code and basically make our own.

Update the flashlight logic (is on, turn on and turn off)

About the Half-Life SDK being used in this tutorial and all the differences with the other SDKs
All the code in this tutorial has been made and tested under Solokiller's Half-Life Updated SDK. So do not be surprised if you see "modern C++ code" (enum class, const...) as well as some differences from Valve's versions (BOOL becomes bool...)

If you are using another Half-Life SDK (2.3/GitHub/Unified/Spirit of Half-Life...) and/or a different toolset than Visual Studio 2019/2022, you are responsible of adapting the code to make it work.

Of course, there are many different ways to code this and everyone has a different code style. That goes beyond the scope of this tutorial so deal with it.
This is NOT a copy/paste tutorial
Because blindly copying/pasting code does not teach you anything and if something goes wrong, you won't know what the code does exactly.

So read the entire tutorial or face the unforeseen consequences of your laziness.
We need to track ourselves the state of the flashlight (on/off), so in player.h (server project), we add this:
enum class FlashlightClientState
{
    Disabled,
    Enabled,
    ForceUpdate
};

class CBasePlayer : public CBaseMonster
{
    // [...]

    bool m_bIsFlashlightOn;
    FlashlightClientState m_eClientIsFlashlightOn;
}
The bool itself is self-explanatory. The reason we have the enum class FlashlightClientState is to synchronize the client with the server and the reason this is not a bool but an enum class with a ForceUpdate value is that you have no guarantee that the flashlight will either be off or on when loading a saved game. That's the purpose of ForceUpdate, to retrieve the actual state of the flashlight and send it to the client (for the HUD and the light that we will add later on).

A tiny side note: FlashlightClientState is not "tied" to the flashlight. In other words, you could use the same enum class for another bool that has no relation to the flashlight.
Important notice for "non-updated" Half-Life SDKs
For single player projects that does not use any "updated" SDK like Solokiller's Half-Life Updated/Unified: use BOOL instead of bool for m_bIsFlashlightOn.

BOOL is in reality an int (typedef int BOOL) and Half-Life's default save/restore code uses that type thus saving/restoring it with a size of 4 bytes (sizeof( BOOL )).

The C++ built bool`'s size is one byte (`sizeof( bool )) and "updated" SDKs have their save/restore code updated to account for that.

This is really important because m_bIsFlashlightOn need to be saved/restored. If you have nasty behavior or unexplicable crashes when saving/loading saved games, this could be the reason.
As mentioned in the warning: our new bool m_bIsFlashlightOn variable need to be saved/restored. So don't forget that DEFINE_FIELD( CBasePlayer, m_bIsFlashlightOn, FIELD_BOOLEAN ) line in the CBasePlayer save/restore table.

Next, we need to update the bool CBasePlayer::FlashlightIsOn(), void CBasePlayer::FlashlightTurnOn() and void CBasePlayer::FlashlightTurnOff() methods. Now we need to synchronize the client and the server together since we wiped out the messages code. We're going to use the same technique as the H.E.V. battery level (m_iClientBattery).

In void CBasePlayer::Spawn(), void CBasePlayer::Precache() and void CBasePlayer::ForceClientDllUpdate(), we're going to force a synchronization whenever the player is spawning, precaching and the client DLL requires an update (loading/saving a saved game as well as transitions between maps). So add m_eClientIsFlashlightOn = FlashlightClientState::ForceUpdate; at these 3 locations.

Let's do the actual synchronization itself: in void CBasePlayer::UpdateClientData(), just above the code that check if you are using a train you can add this bit:
if ( m_eClientIsFlashlightOn == FlashlightClientState::ForceUpdate ||
    (m_bIsFlashlightOn && m_eClientIsFlashlightOn == FlashlightClientState::Disabled) ||
    (!m_bIsFlashlightOn && m_eClientIsFlashlightOn == FlashlightClientState::Enabled) )
{
    MESSAGE_BEGIN( MSG_ONE, gmsgFlashlight, NULL, pev );
        WRITE_BYTE( m_bIsFlashlightOn ? 1 : 0 );
        WRITE_BYTE( m_iFlashBattery );
    MESSAGE_END();

    m_eClientIsFlashlightOn = m_bIsFlashlightOn ? FlashlightClientState::Enabled : FlashlightClientState::Disabled;
}
Here we basically check if we are forced to update or if the flashlight server state does not match the client one. If that's the case, we send the message to the client and keep track of what has been sent to the client.
Notice for Solokiller's Half-Life Updated/Unified SDKs users
You will find the code below in the same method, delete it to prevent interference.
//Tell client the flashlight is on
if (FlashlightIsOn())
{
    MESSAGE_BEGIN(MSG_ONE, gmsgFlashlight, NULL, pev);
    WRITE_BYTE(1);
    WRITE_BYTE(m_iFlashBattery);
    MESSAGE_END();
}
Compile your code, put the updated binaries in your mod and start a map. Play with the flashlight (turn it on, turn it off, save and load games for singleplayer projects) for a little bit.

You will notice that no light appears and that's normal, we haven't added the lighting code yet. What's important to check is if you can still turn the flashlight on, turn it off, is the HUD synchronized with the actual state, if the battery depletes and recharges.

Lighting time!

Time to move to the client project where we will handle the lighting itself.

Let's go in hud.h and more precisely: the CHudFlashlight class. We can declare the dynamic light there (dlight_t *m_pLight;). To make the code cleaner, we will declare a void UpdateFlashlight() method as well and put our new code in there.

In flashlight.cpp, just before returning true in the Draw( float flTime ) method, we call our newly UpdateFlashlight() method.

Time to actually define what void CHudFlashlight::UpdateFlashlight() does. The code being quite complex than what you did earlier, here's the full code of said method (and please read the comments to understand why).
void CHudFlashlight::UpdateFlashlight()
{
    if ( !m_fOn ) // The flashlight is off
    {
        if ( m_pLight ) // We still have our light
        {
            memset( m_pLight, 0, sizeof( *m_pLight ) ); // Reset it's data thus turning it off (thanks Solokiller for the reference to Quake's code)
            m_pLight = nullptr; // De-reference it
        }

        return; // No need to go any further since the flashlight is off
    }

    cl_entity_t *pPlayer = gEngfuncs.GetLocalPlayer(); // Get the local player
    if ( !pPlayer )
        return;

    Vector vecAngles; // Get it's view angles
    gEngfuncs.GetViewAngles( vecAngles );

    Vector vecForward; // Get his "forward direction" (or where he's aiming at)
    AngleVectors( vecAngles, vecForward, nullptr, nullptr );

    Vector vecEnd; // Let's have a range of 2048 Hammer units
    VectorMA( pPlayer->origin, 2048.0f, vecForward, vecEnd );

    pmtrace_t tr; // Make a trace between the player and the max. range, we don't want the flashlight to go through the world and creating it outside of the world
    // You will likely need to include both "pm_defs.h" and "event_api.h" to use these
    gEngfuncs.pEventAPI->EV_SetSolidPlayers( pPlayer->index - 1 );
    gEngfuncs.pEventAPI->EV_SetTraceHull( 2 );
    gEngfuncs.pEventAPI->EV_PlayerTrace( pPlayer->origin, vecEnd, PM_STUDIO_BOX, -1, &tr );

    if ( !m_pLight ) // The flashlight was off?
    {
        m_pLight = gEngfuncs.pEfxAPI->CL_AllocDlight( 1 ); // Create the light with the ID #1 (0 is already used for muzzle flashes)
        m_pLight->die = FLT_MAX; // We don't want the light to "die" right away so we use a very long duration as a "hack" (FLT_MAX need the system's "float.h" include)
    }

    m_pLight->origin = tr.endpos; // Put the light at the end of the trace
    m_pLight->radius = 180.0f; // The radius in Hammer units of the light, 180 is almost the double or maybe x2.5 of Half-Life flashlight's size
    m_pLight->color.r = 255.0f; // Flashlights are usually white, but you want a pink/red/green/purple/magenta/blue/orange or whatever color? Go ahead and update the amount of RGB
    m_pLight->color.g = 255.0f;
    m_pLight->color.b = 255.0f;
}
Getting compile errors due to missing includes?
You might need to add #include "event_api.h" (for traces through event API), and #include <float.h> (for fmax) headers.
Compile the binaries, put them in your mod and test the flashlight. You should now be able to see the light where you are pointing at when the flashlight is on. The light should also be killed when you turn the flashlight off.

And that's it for your client sided flashlight. Go ahead and experiment with different values or try to add things to make it look better!

Bonus - Distance based flashlight

Let's update our new flashlight to change it's radius based on the distance. The closer we are to an obstacle (like a wall), the smaller the light should be.

This is also a good opportunity to play with maths a little. The distance between 2 objects in a 3D space can be determined in a two steps process: To make things interesting, we can have a "short range" kind of thing where the light would get smaller if we get too close to an obstacle.

One important thing: if a dynamic light radius is "0", GoldSrc will consider it's "off" and thus "kill it". This is something we don't want to happen since it could interfere with our server sided logic. So add a "minimum value" like 0.01 to be safe.

Putting all of this together, we could have a new method called GetFlashlightRadius returning a float (to keep the code clean) that would look like this:
float CHudFlashlight::GetFlashlightRadius( const Vector vecSrc, const pmtrace_t tr )
{
    const float flDist = fabs( (tr.endpos - vecSrc).Length() ); // Calculate the distance between the player and where the flashlight is pointed to
    if ( flDist > 512.0f ) // There is more than 512 Hammer units of distance, assume the full radius
        return 180.0f;

    // There is less than 512 Hammer units, divide the distance by the short range to get a multiplier between 0 and 1 and use it on the full radius
    // NEVER use a radius of 0 because that's going to kill the light!
    return fmax( 0.01f, 180.0f * (flDist / 512.0f) );
}
Assign the result of the call to our new function to m_pLight->radius while passing the player origin (pPlayer->origin) and the result of the trace (tr).

If you compile the code and test it, you will notice the light is bigger and brighter when far from an obstacle and the more you get closer to an obstacle, the smaller and dimmer the light get.

Sadly, a limitation of dynamic light is that brightness depends on the radius and there is no way to have those two independently.

Comments

You must log in to post a comment. You can login or register a new account.