- Game Initialization
- Authoring Setup
- Planets
- Buildings
- Ships
- AI
- Entity Initialization and Death
- Acceleration Structures
- VFX
- LODs
All game initialization is handled in GameInitializeSystem. It waits for an entity with a Config component to exist, and waits for Config.MustInitializeGame to be true. Once this condition is met, it initializes the game in these main steps:
- Spawn team managers and team home planets.
- Spawn neutral planets.
- Spawn moons around all planets.
- Spawn initial ships for all teams.
- Spawn initial buildings for all teams.
- Create acceleration structures: spatial database, planet navigation grid, planet network.
- Place camera.
Authoring components in this project rely heavily on the [RequireComponent()] attribute to ensure that all components that are indirectly required by a certain authoring component are also added to the entity. For example:
- Fighter ships always need the general components common to all Ships
- Ships always need Initializable, Team, and Health components (although they're not the only type actor that may need these)
- Health always needs an Initializable component (just like Ships, but Health could be added to objects that aren't ships)
- etc...
By doing the following, we ensure that all component dependencies are added to entities whenever an authoring component is added to a GameObject:
FighterAuthoringhas[RequireComponent(typeof(ShipAuthoring))].ShipAuthoringhas[RequireComponent(typeof(InitializableAuthoring))],[RequireComponent(typeof(TeamAuthoring))]and[RequireComponent(typeof(HealthAuthoring))].HealthAuthoringhas[RequireComponent(typeof(InitializableAuthoring))]
An alternative way of handling this would have been to make sure that FighterAuthoring takes care of adding Ship, Initializable, Health, and Team components directly in the baker. However, a downside of this approach is that any inspectors for Ship, Initializable, Health, and Team would have to be re-created for every type of authoring component that needs these (such as the various building authorings, the authorings for other ship types, etc...). The [RequireComponent()] approach doesn't have this drawback.
Ships and buildings all have some data that is common across all instances and never changes per-instance. In these cases, it can be beneficial to store that data in blob assets, because it reduces the memory footprint of our actors, and reduces the size of our actor archetypes in chunks (leading to improved performance in certain cases). For this, we use the IBlobAuthoring interface in the project. This is a simple interface that objects can implement, and it allows them to act as authorings/bakers for blob assets.
Let's see how this works using the FighterDataObject as an example:
FighterDataObjectis a serializable class that implementsIBlobAuthoring<FighterData>, whereFighterDatais an unmanaged representation of the data that is common to all fighters and doesn't change per instance.FighterDataObjecthas aFighterDataserializable field, and implementsBakeToBlobData(fromIBlobAuthoring). This is responsible for writing the final data to the blob asset.FighterAuthoringis our authoring behaviour, and it has a field of typeFighterDataObject.- During baking, in
FighterAuthoring.Baker, we callBlobAuthoringUtility.BakeToBlobon the authoring'sFighterDataObject, and assign it as aBlobAssetReference<FighterData>in ourFightercomponent. - With all this, we have all instances of the same fighter prefab referencing the same
FighterDatablob asset.
Most planet logic is handled in the PlanetSystem:
PlanetShipsAssessmentJobhandles building a list of ship types per team around each planet. This is used for AI.PlanetConversionJobhandles updating planet conversion when workers are capturing them.PlanetResourcesJobhandles updating planet resource generation.PlanetClearBuildingsDataJobhandles clearing bonuses applied to the planet by the research buildings.
Most building logic is handled in the BuildingSystem:
TurretInitializeJobhandles initialization for turrets.BuildingInitializeJobhandles initialization for buildings.BuildingConstructionJobhandles updating the construction of buildings (insitgated by workers).TurretUpdateAttackJobhandles updating target detection and attacking for turrets.TurretExecuteAttackis a single-threaded job that handles applying damage to entities attacked by turrets. It handles only the part of it that has to happen on a single thread in order to avoid race conditions.TurretUpdateAttackJobis a parallel job responsible for updating attack timers and setting aExecuteAttackcomponent to enabled when ready to attack. Then,TurretExecuteAttackupdates only for entities with an enabledExecuteAttackcomponent, and handles executing the attack.ResearchApplyToPlanetJobhandles making research buildings register their bonuses with their associated planet.FactoryJobhandles updating ship production for factory buildings.
Most ship logic is handled in ShipSystem:
ShipInitializeJobhandles initialization for ships.FighterInitializeJobhandles initialization for fighters more specifically.ShipNavigationJobis a common job for handling navigation for all ship types (fly towards a destination and avoid planets).FighterAIJobhandles target detection, target chasing, decision-making (which planet to go to), and attack updating for fighters.WorkerAIJobhandles decision-making for workers, which means choosing either a planet to capture, or a building to construct on a planet's moon.TraderAIJobhandles decision-making for traders, which means choosing planets to take resources to and from.FighterExecuteAttackJobis a single-threaded job that handles applying damage to entities attacked by fighters. It handles only the part of it that has to happen on a single thread in order to avoid race conditions.FighterAIJobis a parallel job responsible for updating attack timers and setting aExecuteAttackcomponent to enabled when ready to attack. Then,FighterExecuteAttackJobupdates only for entities with an enabledExecuteAttackcomponent, and handles executing the attack.WorkerExecutePlanetCaptureJob: similarly toFighterExecuteAttackJob, this is the single-threaded job that handles executing worker planet capture afterWorkerAIJobdoes most of the work in parallel.WorkerExecuteBuildJob: similarly toFighterExecuteAttackJob, this is the single-threaded job that handles making workers execute building construction afterWorkerAIJobdoes most of the work in parallel.TraderExecuteTradeJob: similarly toFighterExecuteAttackJob, this is the single-threaded job that handles executing trader resource exchanges afterTraderAIJobdoes most of the work in parallel.
AI in this game is implemented using a simple utility AI system.
Most AI calculations happen in TeamAISystem. A TeamAIJob will make each team build lists of possible actions that their ships and buildings can choose from, and will assign an "importance" to each of these actions. Then, ships and buildings will select an action from these lists based on a weighted random, where "importance" acts as the weight.
TeamAIJob computes statistics about known ships, planets, and their surroundings, and builds 4 lists of potential actions:
DynamicBuffer<FighterAction>: contains all the actions that fighters can choose from. There is one action per captured planet or planet that is near a captured planet, which means a fighter's actions are essentially "which planet to go to". For each action, we remember if this is a planet to attack or to defend, and the importance assigned to that action depends on how much the team's AI parameters favors attack over defense.DynamicBuffer<WorkerAction>: contains all the actions that workers can choose from. There is one "construct a building" action per captured planet with a free moon to build something on, and one "capture planet" action per uncaptured planet neighboring our captured planets.DynamicBuffer<TraderAction>: contains all the actions that traders can choose from. There is one action per captured planet, and each action stores information about how many resources this planet has compared to the other captured planets for this team.DynamicBuffer<FactoryAction>: contains all the actions that factories can choose from. These is one action per ship type (because these actions represent "which ship should factories build"), and each ship type is given an importance score that depends on what type of ship this team lacks the most at the moment (among other factors).
Once TeamAIJob has finished computing all these possible actions and their importances for the team, individual actors such as ships and factories will choose from these actions, with some personal bias involved. For example, when a fighter ship chooses an action in FighterAIJob, it applies a "proximity bias" to the importance score of each action. This means that the fighter ship will tend to favor planets that are nearby even if they're not the best-scoring planets. Once the fighter has a final score with personal bias for each action, it will select one action with a weighted random.
See Debug Views for visualizations:
Here is how entity initialization and death events are handled in this game:
Initialization:
- Ship and building entities start off with an enabled
Initializecomponent. - Jobs such as
BuildingInitializeJob,ShipInitializeJob,FighterInitializeJobrun on entities with an enabledInitializecomponent, and handle initialization logic. FinishInitializeSystemupdates towards the end of the frame, and sets anyInitializecomponent to disabled. Disabling this component as a separate step at the end of the frame allows multiple components on the same entity to perform their initialization step, without requiring multiple initialization enabled components.
Death:
- Ship and building entities have a
Healthcomponent. - Jobs such as
BuildingDeathJobandShipDeathJobdetect whenhealth.IsDead()is true (when health reaches 0), and perform some death-related actions. - Finally, a
FinalizedDeathJobruns last and handles destroying the entities withhealth.IsDead()being true.
This game uses 3 main acceleration structures:
The spatial database allows fast querying of entities in a bounding box.
The world is divided in a uniform grid of cells around the origin, and cells store information about which entities are within their bounds. Every frame, a ClearSpatialDatabaseSystem clears all stored data in the spatial database. Then, BuildSpatialDatabasesSystem iterates over all ships and buildings, and adds them to the spatial database (calculate which cell they belong to, and add themselves to this cell).
Once built, the spatial database can be queried using the following functions:
SpatialDatabase.QueryAABB: gets the spatial database cells that would intersect the AABB, and iterates the cells in order of bottom-to-top coordinates.SpatialDatabase.QueryAABBCellProximityOrder: gets the spatial database cells that would intersect the AABB, and iterates the cells in layers around the central cell of the AABB (in other words: in rough order of proximity to center of AABB). This provides interesting optimization opportunities, because when we're interested in finding the closest result to a point in space, we can early-exit out of iterating cells if we have found a valid result in the current cell. This doesn't 100% guarantee that we have found the absolute closest result, but in most case will be a good-enough approximation.
For each spatial database entry in a cell, a byte Team and a byte Type is also stored at the moment of building the spatial database. This allows spatial queries to very quickly pick or discard results based on the team or the actor type of this entity. For example, when fighters need to query for other actors to attack in FighterAIJob (and subsequently in their ShipQueryCollector), they can efficiently discard all results that belong to their own team by comparing the result's team to their own. They do not need to do a ComponentLookup<Team> for each result. Similarly, when planets need to assess what types of ships of which teams are around them in PlanetShipsAssessmentJob (and subsequently in PlanetAssessmentCollector), they can very quickly understand the team and type of ship of iterated results without requiring component lookups.
See Debug Views for a visualization.
The planet navigation grid allow fast planet avoidance for ships.
The world is divided in a uniform grid of cells around the origin. Each cell computes and stores data about the planet that is closest to that cell. During the game, ships use the Planet Navigation Grid to very efficiently process planet avoidance. With simple math, they can calculate the cell index where they currently are, and access the closest planet data at that cell index. Knowing the distance, position, and radius of that closest planet, they are able to compute planet avoidance only on the planet that matters, only when they have to, and without requiring lookups.
The navigation grid is computed once on start in GameInitializeSystem.CreatePlanetNavigationGrid, and is used in ShipNavigationJob when calling PlanetNavigationGridUtility.GetCellDataAtPosition.
See Debug Views for a visualization.
The planets network allows fast neighbor planet searches for AI.
In GameInitializeSystem.ComputePlanetsNetwork, each planet computes a DynamicBuffer<PlanetNetwork>, which holds the X closest planets to this planet.
See Debug Views for a visualization.
All VFX in this game is handled by VFXSystem and VFXGraphs. At the scale required by this game, spawning one VFXGraph GameObject per vfx instance would very quickly become a performance bottleneck. Instead of this, we use a mostly gameObjects-less approach where every instance of a given type of vfx is handled by one single pre-instantiated VFXGraph in the scene. Each vfx instance is a "spawn a VFX here" message sent to a single VFXGraph object through graphics buffers. This approach allows spawning a very large amount of VFX very often, at very little cost.
VFXSystem holds one VFXManager for each type of VFX in the game: laser sparks, explosions, thrusters. VFXManagers hold native collections of vfx requests. During the game, various job will write to these vfx request collections in order to ask for a VFX to be spawned. For example:
FighterExecuteAttackJobcreates requests for laser sparks effects.ShipDeathJobcreates requests for explosion effects.ShipInitializeJobcreates requests to spawn a thruster VFX for this ship, andShipSetVFXDataJobupdates the data that this VFX uses in its update (parent transform data)
At the end of the frame, VFXSystem updates all VFXManagers, who in turn are responsible for uploading their vfx requests to their respective VFXGraphs via graphics buffers:
- Whenever there are VFX events,
VFXSystemwill update the VFXGraph's graphics buffer with the event datas, set a "SpawnRequestsCount" property in the graph, and send a "SpawnBatch" event to the graph. - The VFXGraph will spawn "SpawnRequestsCount" amount of new particles whenever it receives a "SpawnBatch" event.
- For each particle, in the VFXGraph's "Initialize Particle" module, the VFXGraph will use the "Sample Graphics Buffer" node to get the VFXEvent data at the particle's "SpawnIndex" (the particle sequence number in particles we just spawned with the "SpawnBatch" event). It will then use that data to set some particle properties like position and scale.
- In the VFXGraph's "Update Particle" module, we instantly kill the particle using "Set Alive" set to false, and we add GPU events to spawn additional particles on die. In other words; the particle will spawn additional particles on the first frame of its existence and will be destroyed.
- Additional spawned particles then have their own VFX modules (initialize, update, etc...). In "Initialize Particle", they will inherit some particle data from the parent particle that spawned them. They will then use that data as a starting point to control their behavior.
In this project, the LOD needs are very simple due to the fact that most ships are a single entity with a single mesh. Because of this, we can benefit from implementing our own very simple LOD system rather than using the built-in one (which supports more complex cases, but at a significant extra performance cost).
This is implemented via the CustomLOD buffer, CustomLODSystem, and CustomLODAuthoring. Every LOD mesh of every ship/building is its own prefab, under Assets/Prefabs/Ships/LODs and Assets/Prefabs/Buildings/LODs. These LOD prefabs are then assigned in the CustomLODAuthoring in the actual ship/building prefabs.
For each entity with a CustomLOD buffer (holding references to LOD entities), the CustomLODSystem calculates a LOD level based on ship distance to camera, and then simply switches the materialMeshInfo.Mesh to the mesh index of the corresponding LOD mesh in the CustomLOD buffer.
With this custom LOD solution, we therefore improve performance in many ways compared to if we were using the built-in LOD system:
- we avoid the significant performance cost of requiring every ship to be a transform hierarchy that must be updated.
- we reduce the cost of ship instantiation (because ships are now just one entity rather than many).
- we reduce the cost of the LODs update (because our custom LOD solution is simpler and doesn't have to deal with complex cases).
- we reduce the cost of all other jobs that iterate ships (like spatial database building, ship AI, ship movement, etc...), because by avoiding transform hierarchy components and built-in LOD components on our ship entities, we reduce the size of our ship entity archetypes. This means other jobs iterating ships have less data to iterate over, which makes them faster.