Live Coding #2: Building Powerful Games with DOTS by Unity

Published in Offbeat

May 06, 2019

author
Anne-Laure Civeyrac

Tech Editor @ WTTJ

In this live coding session, Alexandre Nicaise, a Unity 3D developer working at Phaethon Games, explains how to use the Data-Oriented Tech Stack (DOTS) in Unity as a way of dealing with performance issues in games. To do this, he will show how to build a ground of 10,000 cubes using DOTS.

What is DOTS?

DOTS is a Data-Oriented Tech Stack of Unity composed of three elements: The entity-component-system (ECS), the jobs system, and the burst compiler.

This improves memory management when compared to object-oriented programming. For example, displaying a ground of 10,000 cubes takes 277 milliseconds normally on Unity, but only 41 milliseconds with DOTS.

Demonstration steps to build a ground of 10,000 cubes

1- Send a game object to a prefab

2- Create an empty game and convert it to an entity

3- Create a component to store the prefab

using System;using Unity.Collections;using Unity.Entities;using Unity.Mathematics;[Serializable]public struct SpawnerDemoData : IComponentData{    // Add fields to your component here. Remember that:    //    // * A component itself is for storing data and doesn't 'do' anything.    //    // * To act on the data, you will need a System.    //    // * Data in a component must be blittable, which means a component can    //   only contain fields which are primitive types or other blittable    //   structs; they cannot contain references to classes.    //    // * You should focus on the data structure that makes the most sense    //   for runtime use here. Authoring Components will be used for     //   authoring the data in the Editor.    public Entity prefab;    public int Ecol;    public int Erow;}

4- Create a proxy to add the component to the entity

using System.Collections.Generic;using Unity.Entities;using Unity.Mathematics;using UnityEngine;[DisallowMultipleComponent][RequiresEntityConversion]public class SpawnerDemoProxy : MonoBehaviour, IConvertGameObjectToEntity, IDeclareReferencedPrefabs{    // Add fields to your component here. Remember that:    //    // * The purpose of this class is to store data for authoring purposes - it is not for use while the game is    //   running.    //     // * Traditional Unity serialization rules apply: fields must be public or marked with [SerializeField], and    //   must be one of the supported types.    public GameObject prefab;    public int column;    public int row;    // Lets you convert the editor data representation to the entity optimal runtime representation    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)    {        SpawnerDemoData data = new SpawnerDemoData        {            // The referenced prefab will be converted due to DeclareReferencedPrefabs.            // So here we simply map the game object to an entity reference to that prefab.            prefab = conversionSystem.GetPrimaryEntity(prefab),            Ecol = column,            Erow = row        };        dstManager.AddComponentData(entity, data);    }    // Referenced prefabs have to be declared so that the conversion system knows about them ahead of time    public void DeclareReferencedPrefabs(List<GameObject> referencedPrefabs)    {        referencedPrefabs.Add(prefab);    }}

5- Create a system to spawn the entity

using Unity.Burst;using Unity.Collections;using Unity.Entities;using Unity.Jobs;using Unity.Mathematics;using Unity.Transforms;using static Unity.Mathematics.math;// JobComponentSystems can run on worker threads.// However, creating and removing Entities can only be done on the main thread to prevent race conditions.// The system uses an EntityCommandBuffer to defer tasks that can't be done inside the Job.public class SpawnerDemoSystem : JobComponentSystem{    // EndSimulationEntityCommandBufferSystem is used to create a command buffer which will then be played back    // when that barrier system executes.    // Though the instantiation command is recorded in the SpawnJob, it's not actually processed (or "played back")    // until the corresponding EntityCommandBufferSystem is updated.     EndSimulationEntityCommandBufferSystem _endSimulationEntityCommandBufferSystem;    protected override void OnCreateManager()    {        // Cache the EndSimulationEntityCommandBufferSystem in a field, so we don't have to create it every frame        _endSimulationEntityCommandBufferSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();    }    // This declares a new kind of job, which is a unit of work to do.    // The job is declared as an IJobForEachWithEntity<LocalToWorld, SpawnerDemoData>,    // meaning it will process all entities in the world that have both    // LocalToWorld and SpawnerDemoData components. Change it to process the component    // types you want.    struct SpawnerDemoSystemJob : IJobForEachWithEntity<LocalToWorld, SpawnerDemoData>    {        // Add fields here that your job needs to do its work.        //A thread-safe command buffer that can buffer commands that affect entities and components for later playback.        public EntityCommandBuffer commandBuffer;         public void Execute(Entity entity, int index, ref LocalToWorld location, ref SpawnerDemoData data)        {            for (int x = 0; x < data.Ecol; x++)            {                for (int z = 0; z < data.Erow; z++)                {                    //Create your entity from the prefab                    Entity instance = commandBuffer.Instantiate(data.prefab);                    //define the position                    float3 pos = math.transform(location.Value, new float3(x, noise.cnoise(new float2(x,z) *0.21f), z));                    //set the position in the world                    commandBuffer.SetComponent(instance, new Translation() { Value = pos});                }            }            //Destroy the spawner entity            commandBuffer.DestroyEntity(entity);        }    }        protected override JobHandle OnUpdate(JobHandle inputDependencies)    {        //Instead of performing structural changes directly, a Job can add a command to an EntityCommandBuffer to perform such changes on the main thread after the Job has finished.        //Command buffers allow you to perform any, potentially costly, calculations on a worker thread, while queuing up the actual insertions and deletions for later.        // Schedule the job that will add Instantiate commands to the EntityCommandBuffer.        var job = new SpawnerDemoSystemJob()        {            commandBuffer = _endSimulationEntityCommandBufferSystem.CreateCommandBuffer() // instantiate the Buffer        }.ScheduleSingle(this, inputDependencies) ;        // SpawnJob runs in parallel with no sync point until the barrier system executes.        // When the barrier system executes we want to complete the SpawnJob and then play back the commands (Creating the entities and placing them).        // We need to tell the barrier system which job it needs to complete before it can play back the commands.        _endSimulationEntityCommandBufferSystem.AddJobHandleForProducer(job);        return job;    }}

The full project is available on GitHub.

This article is part of Behind the Code, the media for developers, by developers. Discover more articles and videos by visiting Behind the Code!

Want to contribute? Get published!

Follow us on Twitter to stay tuned!

Illustrations by WTTJ

Topics discussed