Creating a WebGL Renderer for Unity DOTS
Unity's Data-Oriented Technology Stack (DOTS) is changing the way we architect games, bringing a new level of efficiency and scalability along with it. Its entity-component-system architecture promises blazingly fast operations by maximizing hardware utilization. However, its graphics package lacks support for WebGL. I believe the web has tremendous potential as a platform and DOTS augments that potential.
This post is a guide to creating a WebGL renderer for Unity DOTS so that the power of DOTS can be harnessed on the web. The renderer will use GameObjects and the traditional rendering system to render objects and sync those objects seamlessly with to their corresponing entities in the DOTS world.
Designing an ergonomic API
The first thing I like to do before implementing anything is to think about how things should work for the end user.
Unity's graphics package for DOTS is quite limited compared to the traditional rendering system. Animations for example are not supported. So I wanted to connect GameObjects to entities in a DOTS world. This way, the user can use the traditional rendering system to render objects and sync those objects seamlessly with to their corresponing entities in the DOTS world. If access to the GameObject is needed, it can be retrieved from an entity query:
Entities.WithAll<CharacterTag>().ForEach((Entity e, GameObjectToInstantiate) => {
var gameObject = GameObjectToInstantiate.Value;
var character = gameObject.GetComponent<Character>();
// Do something with the character
}).WithoutBurst().Run();
Creating this link should be seamless and should work with SubScenes. So a MonoBehaviour and Baker was the best choice for defining synced GameObjects.
public class InstantiateGameObjectAuthoring : MonoBehaviour
{
public class Baker : Baker<InstantiateGameObjectAuthoring>
{
public override void Bake(InstantiateGameObjectAuthoring authoring)
{
var e = GetEntity(TransformUsageFlags.Dynamic);
AddComponentObject(e, new GameObjectToInstantiate { value = authoring.gameObject });
}
}
}
Instantiation and Cleanup
Before we can sync GameObjects with entities, we need to instantiate them. Here we create a system that does that:
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.LocalSimulation)]
public partial class GameObjectInstantiatorSystem : SystemBase
{
protected override void OnUpdate()
{
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.TempJob);
Entities.WithNone<InstantiatedGameObject>().ForEach((Entity e, GameObjectToInstantiate go) =>
{
var instantiatedGO = GameObject.Instantiate(go.value);
ecb.AddComponent(e, new InstantiatedGameObject { value = instantiatedGO });
ecb.AddComponent<InstantiatedGameObjectTag>(e);
}).WithoutBurst().Run();
// Cleanup
Entities.WithAll<SyncedGameObject>().WithNone<SyncedGameObjectTag>().ForEach((Entity e, SyncedGameObject go) =>
{
GameObject.DestroyImmediate(go.value);
ecb.DestroyEntity(e);
}).WithoutBurst().Run();
ecb.Playback(EntityManager);
ecb.Dispose();
}
protected override void OnDestroy()
{
Entities.ForEach((Entity e, SyncedGameObject go) =>
{
GameObject.DestroyImmediate(go.value);
}).WithoutBurst().Run();
}
}
Corresponing Child GameObjects to Child Entities
The hardest part of this problem is figuring out which GameObject corresponds to which Entity in the children of the InstantiatedGameObject. The way I've done this is by traversing the heirarchy of the GameObject and Entity and matching them up by name. This is only needs to be done once, so it's not a big deal if it's not very efficient.
There are two parts to this. The first is creating a Transform baker that stores the name of the GameObject at bake time into a ComponentData on the Entity:
public class TransformBaker : Baker<Transform>
{
public override void Bake(Transform authoring)
{
var igo = authoring.gameObject.GetComponentInParent<InstantiateGameObjectAuthoring>(true);
if(igo == null)
{
return;
}
var e = GetEntity(TransformUsageFlags.Dynamic);
AddComponent(e, new SyncTransfromTag());
AddComponent(e, new GameObjectId { value = authoring.transform.name });
}
}
Now that entities containing a Transform have a name associated with them in DOTS land, we can use that to match them up with their corresponding GameObjects.
Here's the interesting bits of that logic:
var ecb = new EntityCommandBuffer(Allocator.Temp);
// Sync the InstantiatedGameObject
Entities.WithNone<SyncedGameObject>().WithAll<SyncTransfromTag>().ForEach((Entity e, InstantiatedGameObject igo) =>
{
ecb.AddComponent(e, new SyncedGameObject { value = igo.value });
ecb.AddComponent<SyncedGameObjectTag>(e);
}).WithoutBurst().Run();
NativeHashMap<Entity, Parent> parentLookup = new NativeHashMap<Entity, Parent>(10, Allocator.Temp);
// Populate parent lookup
Entities.WithNone<SyncedGameObject, InstantiatedGameObject>().WithAll<SyncTransfromTag>().ForEach((Entity e, Parent p) =>
{
parentLookup.Add(e, p);
}).WithoutBurst().Run();
// Find InstantiatedGameObject given a Transform
Entities.WithNone<SyncedGameObject, InstantiatedGameObject>().WithAll<SyncTransfromTag>().ForEach((Entity e, GameObjectId id, Parent p) =>
{
var heirarchy = new List<string>();
Entity currentEntity = e;
InstantiatedGameObject igo = null;
// Populate the heirarchy up to the InstantiatedGameObject
while (igo == null)
{
heirarchy.Add(EntityManager.GetName(currentEntity));
if(EntityManager.HasComponent<InstantiatedGameObject>(currentEntity))
{
igo = EntityManager.GetComponentData<InstantiatedGameObject>(currentEntity);
break;
}
if (parentLookup.TryGetValue(currentEntity, out var parent))
{
currentEntity = parent.Value;
} else
{
Debug.LogErrorFormat("Entity doesn't have parent: {0} when looking for InstantiatedGameObject for: {1}", EntityManager.GetName(currentEntity), EntityManager.GetName(e));
break;
}
}
// Remove the InstantiatedGameObject from the heirarchy
heirarchy.RemoveAt(heirarchy.Count - 1);
// If we didn't find the InstantiatedGameObject, log an error and return
if (igo == null)
{
EntityManager.GetName(e, out var name);
Debug.LogErrorFormat("Could not find InstantiatedGameObject in parents for Entity: {0}", name);
return;
}
// Find children one at a time starting from the InstantiatedGameObject until we've exhausted the heirarchy. At that point, we found the matching GameObject for this entity
GameObject currentGO = igo.value;
while(heirarchy.Count > 0)
{
var name = heirarchy[heirarchy.Count - 1];
heirarchy.RemoveAt(heirarchy.Count - 1);
currentGO = currentGO.transform.Find(name).gameObject;
if(currentGO == null)
{
Debug.LogErrorFormat("Could not find GameObject in parents for Entity: {0}", name);
return;
}
}
if(heirarchy.Count > 0)
{
Debug.LogErrorFormat("Could not find GameObject in parents for Entity: {0}", EntityManager.GetName(e));
return;
}
ecb.AddComponent(e, new SyncedGameObject { value = currentGO });
ecb.AddComponent<SyncedGameObjectTag>(e);
}).WithoutBurst().Run();
ecb.Playback(EntityManager);
Transform Syncing
The hard work is now done, all that's left is to sync the transforms of the GameObjects with the LocalToWorld transforms on the entities:
public partial class TransformSyncSystem : SystemBase
{
protected override void OnUpdate()
{
Entities.WithAll<SyncTransfromTag>().ForEach((Entity e, SyncedGameObject go, LocalToWorld ltw) =>
{
if (go.value == null)
{
return;
}
go.value.transform.SetPositionAndRotation(ltw.Position, ltw.Rotation);
var scale = ltw.Value.Scale();
go.value.transform.lossyScale.Set(scale.x, scale.y, scale.z);
}).WithoutBurst().Run();
}
}
Conclusion
And that's it. I hope that helps. I'll be posting a Unity Asset Store package for this soon (once it get's through review) but I hope this code is helpful for those of you who want to do this yourself.