INTRODUCTION

I've always loved procedural generation, and this Lightning System was one of my first chances to develop a procedural system professionally. In 2017, Sanzaru Games was developing to exclusive Oculus VR titles;

As part of the Core Tech team, the idea was to develop systems that could be used by multiple titles. Unlike the Ocean technology I helped construct for “Asgard's Wrath”, the Procedural Lightning was mostly done directly in C++, rather than Material Editor and Blueprint. I developed a Node-based system where a lightning path would be generated between one Source and Target point(s), splitting off into N number of Branches along the way. For the benefit of the VFX Artists, I made sure every feature, ( offset probability, number of Nodes, etc ) was tweakable directly from Unreal 4's Editor. During initial development, the Procedural Lightning's features kept expanding, and eventually went through a complete rewrite. The Lightning System was developed for both cutscenes and gameplay mechanics!

PROJECT GOALS

Our Visual Art Director was not happy with the electricity VFX that he created in Unreal's Cascade. While the visuals looked close to lightning, he felt that it was too pattern-like, not chaotic and random enough. What he especially wanted was; dynamic lightning paths; to fork randomly into multiple branches as it traveled.

 

For truly controllable and versatile lightning, the whole vfx would have to be done procedurally. So, in our Unreal C++ codebase, I created a new Actor Component class, and started to work on the Procedural Lightning.

LIGHTNING TERMINOLOGY

For research, I looked at a ton of lightning footage ( especially slow motion ) and a couple of other procedural lightning systems too. To my knowledge, there is no scientific terminology to describe each part of the lightning. So, I invented my own labels based on how I was going to structure the system.

​​​

Using my own terminology, lightning is made up of of many Branches. Each Branch has a Source Node it originates from and a Target Node it travels towards. The Branch paths are plotted with N number of Branch Nodes. I call the lightning between two Branch Nodes a Bolt. Each Bolt path is plotted between the two Branch Nodes with N number of Strand Nodes. Lastly, the Lightning is made up of mesh quads. I call each quad a Strand.

 

I’ll go into forking branches later on. I refer to Breaking Off as to when a new Branch spawns off from a Branch Node. Forking is the same idea, but for two new Branches “spawning off” simultaneously. It’s not scientific at all, but the standardized terminology really helped me communicate with team members on specific features of the lightning.

DATA STRUCTURE

The hardest part of this assignment was indeed nailing down the Procedural Lightning code structure. The system needed to consist of multiple branch paths, with each branch featuring its own Source and Target Nodes ( along with many Nodes in-between ). I tried several methods, including two-dimensional arrays and doubly linked lists. At first, those seemed obvious for a procedural system like this, but they didn’t work out for me. Rather than working with an actual hierarchical system of lightning branches and sub branches, I ended up using a single Unreal TArray of my FLightning struct.

 

For me, it was cleaner to Not step in and out of an actual Node hierarchy. Despite it's visual tree-like structure, each branch was simply a sequential set of Nodes; only the parent branch would need to be kept track of. The procedural mesh variables were a series of TArrays. Navigating the single array, the FLightning struct simply indexed the start and end points for its given Nodes.

MEMORY MANAGEMENT

At BeginPlay(), all memory is pre-allocated to max capacity, and never is re-sized again. In the Editor, The artists are able to tweak the maximum number of potential branches and Nodes spawned. The TArray capacities are set to those max values, even if the chance of that number being reached is as low as 10%. Working from a fixed size is simpler ( and I don’t have to worry about stepping out of bounds ). The Procedural Lightning is intended to restart many times in only a couple seconds. It wouldn’t make sense to constantly add and remove branches from the TArrays because of how fast the lightning recalculates a new Node path. New Branches are randomly “spawned”, but the finite state machine handles setting them active or inactive. No matter how many Strands or Bolts are on-screen, the lightning’s memory footprint does not change on the CPU-side.

// Allocate GPU Memory ( Preallocate TArray Count when creating Mesh for first time )
void UProceduralLightning::AllocateMemory()
{
    m_vtx_list.SetNum(  m_nodeCount );
    m_uv_list.SetNum(   m_nodeCount );
    m_idx_list.SetNum(  m_nodeCount * 6 );
    m_normal_list.SetNumZeroed(  m_nodeCount );
    m_tangent_list.SetNumZeroed( m_nodeCount );
    m_color_list.SetNumZeroed(   m_nodeCount );
    CreateMeshSection( 0, m_vtx_list, m_idx_list, m_normal_list, m_uv_list,   m_color_list, m_tangent_list, false );
}

FINITE STATE MACHINE

At first, I was resistant to using a finite state machine system, but caved once I found myself adding several boolean flags to compartmentalize stages of the Lightning! Sanzaru Games doesn’t have a stranderdized state logic system for Unreal that all of us programmers use. So, I created my own very simple system within the Lightning class. The LightningState enum contains only four states, and each FLightning struct keeps track of it’s own previous, current, and future states. The TickComponent() loops through the entire BranchList each frame. If a branch has been set to a new LightningState, it will call ExitState() followed by InitState(). For every other frame, UpdateState() is called. Those functions are pretty simple, a few universal lines, and switch case to the specific LightningState functions. Ultimately, the state logic is very basic, but helps me compartmentalize the branches and easily keep track of when the lightning has begun and finished.

CALCULATING PATH

The Procedural Lightning’s shape is created by plotting out a series of Branch Nodes using Vectors. It’s all handled in the CalculateBranchPath() function. Its pretty simple, The Branch starts at the Source Node and makes incremental steps towards the Target Node. The Branch’s Normal is the direct path to the Target Node, while its Tangent and BiNormal are used for randomized offset and for setting broken off branch paths.

The Branch Length is divided by the number of Branch Nodes to get the magnitude of each Strand. I keep track of the current and previous Node position, which is continually pushed toward the Target Node position. Every Branch Node is iterated upon within a for loop; incrementing the current Node position and adding to the Node list. To give the lightning its zigzaggy look, a random Tangent and BiNormal offset value is calculated and added to the Node position.

REACHING ITS TARGET

Randomizing the direction of the Branch Path is vital in creating lightning visuals. However, because the Strands are at a fixed scale and count, it was possible that the Lightning Branch would not always reach it’s Target Node. In order to make sure the Lightning has a crazy Path, but always reach it’s destination in N amount of nodes, I added a Lerp between the random vector and the direct vector to the Target Node. The Lerp’s blending float value is the Node count divided by Node Total. Basically, as the Branch is running out of Nodes, it favors the direct path more and more. I also cheated, by stretching the scale of the Strands ( distance between two Nodes ) to ensure that Branch would reach it’s Target.

BREAKING OFF AND FORKING

I’d say the most important feature of the Procedural Lightning is its ability of breaking off and forking. Randomly spawning off new Branch Paths is what really separates this visual feature from one’s created solely in the material editor and Cascade. In the Inspector, the Artist is able to control the amount and probability that a Branch will break off into one or two new Branches. Whether or not a Branch will break off is decided at the end of the CalculateBranchPath() for loop.

​​​

 

// Decide to Breakoff Branch
bool  randChance    = FMath::RandRange( 1, BreakOffOdds ) <= 1;
int   breakNode     = m_node_list.Num()-1;
bool  toBreakOff    = randChance && ( breakNode > branch.id_Start && breakNode < branch.id_End-1 );
if ( toBreakOff )
{
    branch.breakOffList.Push( breakNode );
}

When breaking off occurs, the Branch Node is pushed into a list. After the entire NodeList is populated for the current Branch, a foreach loop occurs that recursively calls CalculateBranchPath() based on number of branches broken off. It’s at this point that the new Branch Target is calculated using the parent Branch’s Tangent vector and a few tweakable variables. The new Branch struct depth is set to parent’s depth + 1 here to keep track of the Branch “genealogy”. Lastly, when Forking is decided ( 50:50), than two Branches are calculated. The second new Branch’s Target Node is simply mirrored along the parent’s Normal vector.

ACTOR CONDUIT NODES

Now typically, all Sub Branches derive their Target Node based off the Tangent direction of their Parent Branch plus a randomized degree offset. However, the Procedural Lightning also allows using scene Actors as both Target Nodes and Conduit Nodes. The Actors are stored in a TArray and every Branch that breaks off simply takes the next Actor’s position from the list. During gameplay, populating the Actor Node via Blueprints could result in some pretty awesome dynamic effects such as Chain Lightning attacks against a string of enemies. If the Actor list reached it’s end or is empty, than a regular Target Node will be calculated. Typically, only the Main Branch and its immediate Sub Branches should gravitate towards Actor Nodes. Branches with a higher depth than 2 are small tendrils, and really are only used as window dressing at that point.

USING CASCADE

Originally, the Procedural Lightning was rendered by daisy-chaining a series of Unreal Particle System instances together. It seemed an obvious choice, it would give the VFX Artists a lot of freedom to manipulate the lightning visuals. Cascade also features vector parameters to define the start and end of the VFX quad. Lastly, billboarding was available so the quads would always face the camera.

However, the Lightning could potentially be made up of hundreds of Strands…meaning hundreds of ParticleSystemComponent instances. Instead of the super costly method of spawning for each Strand, I instead pre-allocated a user-defined number of vfx at BeginPlay(). The DrawStrand() function simply reused the oldest Strand vfx for the newest ones in an endless loop. Since the lightning draws so fast, it’s not a problem. In fact, it can give the impression that the lightning is crawling down a line.

OVERHAUL

At first, my Lightning class was created with a fire-and-forget structure. That worked for the initial design scope; lightning strikes from the sky, or other static Source and Target points. However, the Technical Art Director continued to give feedback on the project, and in-turn, I continually needed to add more arrays and variables. In particular, the Technical Art Director wanted elaborate oscillation and transformation from the lightning branches. He wanted to use Procedural Lightning between the Player’s hands ( controlled by Oculus Touch Controllers ). The Procedural Lightning class became so bloated and unwieldy, that I decided for a complete fresh start; and build it stronger.

USING PROCEDURAL MESH

When revising the Lighting, the biggest change was replacing the expensive ParticleSystemComponents with a Procedural Mesh. First thing, was to make the Procedural Lightning a child of Unreal's UproceduralMeshComponent class. Branch Path calculating works very closely to what I did before. The biggest difference is I'm working 100% in Local Space rather than World, because that's what mesh generation works under.

 

After all the branch paths are calculated, the new CreateMeshSection() function is called.

It's a pretty straight forward; vertex, index, normal buffers etc. Unreal's CreateMeshSection() had way too many parameters, and it would have been wise to define a version with less parameter inputs. The most unique thing about CreateMeshSection() is that it let's you divide up your Procedural Mesh into “sections”, not unlike an fbx file exported with two meshes. For the Lightning, I kept the entire mesh all in one set of buffers, but the idea of pairing each branch into sections did come to mind.

 

The tricky thing was billboarding with the Procedural Mesh ( that was one major advantage to Cascade ). Since I'm now keeping track of all Branch Nodes in TArrays, I simply iterate through them and take a Cross Product of the CameraView Direction and Branch's Normal to get the perpendicular direction. For each Branch Node, two vertices are collapsed, but then extruded based on the Cross Product. This is accomplished with a compute shader that a fellow Core Tech engineer kindly took care of for me.

BRANCH OSCILLATION

Each Branch Node oscillates a specified distance from it’s origin along its Tangent and BiNormal Vectors. The Branch struct holds the rate and distance that each Branch can move. When I was using a string of VFX quads, the oscillation was potentially creating gaps between the Strands. Using a Procedural Mesh with stitched vertices for the quads solved this problem.

CONCLUSION

The Procedural Lightning was very exciting to accomplish. The process was bumpy though because of feature-creep. In 2017, Sanzaru's Core Tech tasks were intended to be general-purpose, with multiple uses. If the Lightning was meant for only one very specific gameplay feature, it would have been easier to plan for.

 

Actually, “canned” lightning effects (pre-rendered) look pretty good, and don't require so much CPU-overhead. It was a mistake for the lightning to be attempted for Cutscenes. The real beauty of procedural work though is responding to dynamic elements during gameplay. Watching the Lightning reach out to nearby enemies or grabbable objects was be a sight to behold. There were way too many parameters exposed for Artists, general-purpose really hurting it in this manner. However, tuned correctly, the results could be very convincing!

  • First posted in June 2017