Skip to content

Instantly share code, notes, and snippets.

@joeante
Last active September 24, 2021 18:45
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save joeante/79d25ec3a0e86436e53eb74f3ac82c0c to your computer and use it in GitHub Desktop.
Save joeante/79d25ec3a0e86436e53eb74f3ac82c0c to your computer and use it in GitHub Desktop.

Requirements:

  • Reading Global T | R should be speed of light (reading 3/4 floats)
  • Writing Global T | R of root entities should speed of light (writing 3/4 floats)
  • Writing Global T | R of non-root entities should be correct, and not slow [Correct on a per entity]
  • API is as easy as UnityEngine.Transform
  • Game code can run in parallel
  • Game code doesn't have to annotate things by default and can use TransformAspect local|global Position|Rotation values with zero hassle
  • Unnecessary / immovable Transform hierarchy elements are eliminated by default
  • Easy & robust way instantiate an entity at a location (Instantiating a prefab). It must work for static & dynamic objects.
  • Easy to offset a set of entities (Shifting origin scenes)
  • Game code can easily work with uniform scale, non-uniform scale is discourage via the API.
  • Streaming hierarchies in / out has minimal impact on TransformSystem performance
  • TransformSystem uses ISystemBase instead of SystemBase

Impact

  • We will actively destroy / remake hierarchies when they are unnecessary, based on what is referenced. Visual Script can NOT expose the children / parent API for that reason. Documentation needs to be clear that children / parent components should not be used for game code purposes API's.
  • Dots.Editor need to visualize what is happening with hierarchies and why.team needs to think about how too visualize on visualizing what will happen with hierarchies
  • Game code can easily work with uniform scale. All runtime systems must support uniform scale.
    • Non-uniform scale can be used at authoring time and may get baked out at conversion time (eg. physics will bake out any non-uniform scale at conversion time into the collision mesh)

Conversion approach:

  • We preserve the child relationships only if a parent might move at runtime
    • This lets us by default strip out many transforms and lets user not worry about unused inbetween transforms
    • Global space values will always match exactly
    • This means local space values of the game object transform might be different compared to the entity transform
    • Hence game code can not rely on specific local space values. I believe that problems arising from this will be very rare and thus worth it.
  • GetPrimaryEntity reference declares TransformUsage explicitly when requesting the Entity. Thus it is impossible to forget to declare it.
    • Referencing an entity or adding a component is thus handled the same way in terms of declaring transform usage
    • If there is only a Transform component on a game object and no one referenced the game object, then no one calls GetPrimaryEntity and thus the entity will never be created in the first place.

enum TransformUsageFlags
{
	// No transform components are required
	None,

	// User might read / write local/global position/rotation,
	Default,

	// Automatically forces the transform to be in the root
	WriteGlobalTransform,

	// We will only read from the transform component
	// Hence the entity is immovable
	ReadGlobalTransform
}

class GameObjectConversionSystem
{
	public Entity GetPrimaryEntity(GameObject gameObject, TransformUsage usage = TransformUsageFlags.Default);
}

struct GlobalTranslation : IComponent
{
	public float3 Value;
}

struct GlobalRotation : IComponent
{
	public quaternion Value;
}

struct LocalRotation : IComponent
{
	public quaternion Value;
}

struct LocalTranslation : IComponent
{
	public float3 Value;
}

struct Parent : IComponent
{
	public Entity Value;
}

struct Child : ISystemBufferElement
{
	public Entity Value;
}


// Game code can safely interact with uniform scale
struct LocalScale : IComponent
{
	public float Value;
}

// Game code can safely interact with uniform scale
struct GlobalScale : IComponent
{
	public float Value;
}


// Any non-uniform scale is packed into PostTransformation struct at conversion time.
// Users are NOT expected to work with it, no code is expected to handle changes to it at runtime.
// Physics for example, bakes non-uniform part of scale into the collision mesh, 
// while the uniform Scale value remains change-able at runtime with the above component
struct PostTransformation : IComponent
{
	public float3x3 Scale;
}

struct ParentTransform : IComponent
{
	public float4x4   Matrix;
	public quaternion Rotation;
}

// NOTE: LocalToWorld will be removed, all the other maya style transform components will be removed too


struct TransformAspect : IComponent
{
	GlobalPosition* G_T;
	GlobalRotation* G_R;
	
	[Optional]
	LocalPosition* L_T;
	[Optional]
	LocalRotation* L_R;
	
	[Optional]
	ParentTransform* P_M;

	// Global space
	public float3 Position 
	{
		get
		{
			return *G_T;
		}
		set
		{
			*G_T = value;
			// Optional, code-gen compiles it away
			if (P_M != null)
				*L_T = mul(inverse(*P_M->Matrix), value);
		}
	}


	// Global space
	public quaternion Rotation 
	{
		get
		{
			return *G_T;
		}
		set
		{
			*G_T = value;
			// Optional, code-gen compiles it away
			if (P_M != null)
				*L_T = mul(inverse(P_M->Rotation), value);
		}
	}


	// Local space
	public float3 LocalPosition
	{
		get
		{
			// Optional, code-gen compiles it away
			if (L_T != null)
				return *L_T;

			return *G_T;
		}
		set
		{
			*L_T = value;

			// Optional, code-gen compiles it away
			if (P_M != null)
				*G_T = mul(*P_M->Matrix, value);
		}
	}

	// Global space
	public quaternion LocalRotation 
	{
		get
		{
			// Optional, code-gen compiles it away
			if (L_R != null)
				return *L_R;
			return *G_R;
		}	
		set
		{
			*L_R = value;

			// Optional, code-gen compiles it away
			if (G_R != null)
				*G_R = mul(*P_M->Rotation, value);
		}
	}

	public float LocalScale get; set;
	public float Scale get; set;

	// Move relative to global rotation
	public void Translate(float3 translation);
	// Rotate in global space
	public void Rotate(quaternion rotation);
	// Change global rotation
	public void LookAt(float3 position, float3 up = float3(0, 1, 0));


	// Moves a set of entities.
	// * Could be a set of entities belonging to a scene.
	// This works correctly for both dynamic & static entities.
	// This is slower than using Translate directly and intended to be used rarely.
	// The intended usage is for offsetting objects when instantiating 
	// or for origin shifting scenes.
	public static void MoveEntities(EntityQuery query, float3 position, quaternion rotation);
	public static void MoveEntities(EntityQuery query, float3 position, quaternion rotation);

	// Moves a set of entities.
	// A prefab / LinkedEntityGroup might for example contain multiple root objects.
	// This will move all root objects in that prefab.
	// This works for both static & dynamic objects
	public static void MoveEntities(EntityManager em, Entity rootEntity, float3 position, quaternion rotation);
	public static void MoveEntities(EntityCommandBuffer em, Entity rootEntity, float3 position, quaternion rotation);
	public static void MoveEntities(EntityManager em, NativeArray<Entity> rootEntity, float3 position, quaternion rotation);
	public static void MoveEntities(EntityManager em, NativeArray<Entity> rootEntity, NativeArray<float3> positions, NativeArray<quaternion> rotations);

	// Add the necessary transform components (Optional parenting)
	public static AddComponents(EntityManager em, Entity entity, Entity parent = Entity.Null);

	// Change the parent of an entity
	public static void SetParent(EntityManager em, Entity entity, Entity parent, bool keepGlobalSpace = true);
	public static Entity GetParent(EntityManager em, Entity entity);
}

struct EntityManager
{
	// For convenience that Instantiate method has 
	// an overload with optional position / rotation
	Entity Instantiate(Entity prefab, float3 position, quaternion rotation)
	{
		var instance = Instantiate(prefab);
		TransformAspect.MoveEntities(instance, position, rotation);
	}
}

struct EntityCommandBuffer
{
	// EntityCommandBuffer handles offsetted instantiation
	Entity Instantiate(Entity prefab, float3 position, quaternion rotation);
}


// Automatically places a TransformSystem between this and any previous system that might write transform components
[RequireSynchronizeGlobalSpaceTransformsAttribute]

Simple game code by using higher level methods

class MySpaceSystem : SystemBase
{
	void OnUpdate()
	{
		Entities.ForEach(TransformAspect transform, in SpaceShip ship) =>
		{
			// Change the rotation using simpler euler angle style API
			transform.Rotate(0, ship.RotationSpeed * Time.DeltaTime, 0);

			// Translate moves the space ship relative to the global space rotation
			transform.Translate(ship.Speed * Time.DeltaTime);

		}.Schedule();
	}
}

Rendering code usage

struct RenderMesh : IComponentData {}

struct MeshRendererConversion : SystemBase
	ForEach(MeshRenderer renderer)
		// Explicitly declares that it only reads the transform, 
		// thus entities with meshrenderer
	    // are not automatically tagged as movable
		GetPrimaryEntity(renderer, ReadGlobalTransform);
		...


// Mesh Renderer -> GPU upload 

// fastpath: 8 floats	
quaternion rot;
float3     pos;
float      scale;

// Slow path non-uniform scale: 16 floats
quaternion rot;
float3     pos;
float3x3   postTransformation;

MeshRenderer by default is static optimized / hierarchy removed

- SomeGroup[Transform]
	- House [Transform, MeshRenderer] 

Converted to

- House (MeshRenderer, G TR)

Because the only component on House is a MeshRenderer and we know it only reads global space transform. There is no need for a hierarchy or position / rotation values

MeshRenderer doesn't move objects thus no hierarchy is necessary

- Building (MeshRenderer, Transform)
	- Wall (MeshRenderer, Transform)
		- Window (MeshRenderer, Transform)

Converted to

- Building (MeshRenderer, Global TR)
- Wall (MeshRenderer    , Global TR)
- Window (MeshRenderer  , Global TR)

By default any IComponentData implies that we might read / write Local or Global space

struct MySpaceShipAuthoring : MonoBehaviour
	...
	Convert()
		var entity = GetPrimarEntity(this);
		DstEntityManager.AddComponentData(entity, new MySpaceShip());
		// Same as:
		// var entity = GetPrimarEntity(this, TransformUsage.Default);

struct MySpaceShip : IComponentData {}
- SpaceShip [Transform, MySpaceShipAuthoring]
	- SpaceShipMesh [Transform, MeshRenderer]

Converted to

- SpaceShip [MySpaceShip, Global  TR]
	- SpaceShipMesh [MeshRenderer, Global/Local TR]

MySpaceShipAuthoring by default declares that it might read/write the transform, thus the hierarchy is kept

Parent Transforms that are not referenced at runtime are stripped out

- GroupWhereIPutAllMySpaceShipsInTheEditor [Transform]
	- SpaceShip [Transform, MySpaceShipAuthoring]
		- Body [Transform, MeshRenderer]

Converted to

- SpaceShip [MySpaceShip, Global TR]
	- Body [MeshRenderer, Global/Local TR]

GroupWhereIPutAllMySpaceShipsInTheEditor is not referenced, thus will not exist at all. SpaceShip is referenced and body must be attached because MeshRenderer reads the data.

Inbetween transforms that are never referenced are stripped out

- SpaceShip [MySpaceShipAutoring, Transform]
	- Body [MeshRenderer, Transform]
	- Weapons [Transform]
		- Left Weapon [MeshRenderer, Transform]
		- Right Weapon [MeshRenderer, Transform]

Converted to

- SpaceShip [MySpaceShip, Global TR]
	- Body [MeshRenderer, Global/Local TR]
	- Left Weapon [MeshRenderer, Global/Local TR]
	- Right Weapon [MeshRenderer, Global/Local TR]

Weapons is never referenced, thus will simply not be converted. Left Weapon / Right Weapon are thus directly parented to SpaceShip, since SpaceShip is movable.

Game Manager can opt out of wanting to have Transform components

struct MyGameManager : IComponentData

struct MyGameManagerAuthoring : MonoBehaviour
	...
	Convert()
		var entity = GetPrimarEntity(this, TransformUsage.None);
- MyGameManager [MyGameManagerAuthoring]  

Converted to

- MyGameManager [MyGameManager]  

No Transform components added because explicitly declared that its not required.

A rigid body apple in a basket (With a mesh collider). Both become root objects!

struct Rigidbody : IComponentData { float3 pos, quaternion rot;}

struct RididbodyAuthoring : MonoBehaviour
	...
	Convert()
		// This results in automatic unparenting during conversion.
		// Local Translation and Rotation is removed.
		var entity = GetPrimarEntity(this, TransformUsage.ForceRootTransforms);


class PhysicsSystem : SystemBase
{
	Entities.ForEach(TransformAspect transform, Rididbody body)
	{
		transform.Position = body.pos
		transform.Rotation = body.ros;
	}
}

- Basket [Transform, MoverObject, Kinematic Rigidbody, Collider]
	- Apple [Transform, MeshRenderer, Rigidbody]

Converted to

- Basket [MoverObject, Global TR]
- Apple [MeshRenderer, Rigidbody, Global TR]

Apple becomes a root object because Rigidbody declares TransformUsage.ForceRootTransforms. The apple moves with the basket due to physics simulation, not because of a parenting relationship.

Animation system by default strips out all entities in the hierarchy that are not referenced / no attached IComponentData

- MyCharacter -> Transform, AnimationRig
	- RootBone [Transform]
		- shoulder [Transform] [part of anim rig] (Referenced)
			- uppershoulder [Transform] [part of anim rig] (deleted) 
				- arm [Transform]  -> Values Driven by the Animation system
					- sword-socket [Transform, Meshrenderer]  -> kept
						- sword-shaft [Transform, Meshrenderer]  -> kept

->

- MyCharacter -> AnimationRig, G TR
// Because referenced by a game object during conversion
- shoulder [Transform] (Referenced ) -> Root Global TR etc... Driven by the Animation system
// Because this is the leafiest transform that the animation system can output to & child objects have mesh renderers (which animation system doesn't know about)
- arm [Transform] -> Root Global TR etc... Driven by the Animation system
	- sword [Transform, Meshrenderer] -> Child Global | Local TR, driven by parent
		- sword-shaft [Transform, Meshrenderer] -> Child Global | Local TR, driven by parent

Animation system can pick up the usage and adjust it before the transform components actually get added

// Happens after all other conversion has completed
class LateAnimationConversion : GameObjectConversionSystem
{
	foreach(var entity in ConversionSystem.GetReferencedEntitiesInSubHierarchy(rootAnimationGameObject))
	{
		var usage = TransformConversion.GetUsage(entity);

		if (IsPartOfRig(rootAnimationGameObject) && usage != None)
		{
			// This transform will become a root
			TransformConversion.SetUsage(entity, WriteGlobalT | WriteGlobalR);
			// ... Add Entity to root animator and drive it every frame
		}
	}
}

Tank with a turret rotates the turret towards the turret of the targeted tank

struct MyTurret : IComponentData
{
	Entity OtherTurret;
	// Rotate at this speed
	float  RotationSpeed;
}

[UpdateAfter(typeof(PhysicsSystem))]
// Automatically places a TransformSystem between this and any systems that write transform components
[RequireSynchronizeGlobalSpaceTransforms]
class TurretSystem : SystemBase
{
	Entities.ForEach(TransformAspect transform, in MyTurret turret)
	{
		var targetPos = GetAspect<Transform>(turret.OtherTurret).Position;
	 	var targetRotation = quaternion.LookRotation(targetPos - transform.Position, float3(0, 1, 0));

		// Writing Global Rotation,
		// but internally the value is immediately written 
		// to the local rotation value.
		// Thus when the Tank rotates / moves, 
		// the local rotation is retained 
		// while the global rotation can be recomputed,
		// when the TransformSystem updates.
		transform.Rotation = quaternion.RotateTowards(transform.Rotation, targetRotation, turret.RotationSpeed);
	}.ScheduleParallel();
}
Tank (Rigidbody, Transform, MeshRenderer)
	- Turret (MyTurret, Transform, MeshRenderer)

Converts to

Tank (Rigidbody, MeshRenderer, Global TR)
	- Turret (MyTurret, MeshRenderer, Global/Local TR)

Instantiating a prefab at a specific location

Instantiate supports position / rotation natively. Works for static / dynamic objects. Even if the prefab created multiple roots because none of the objects were determined movable.

class Spawning : SystemBase
{
	void OnUpdate()
	{
		EntityCommandBuffer cmd ...;
		Entities.ForEach(TransformAspect transform, in Spawning spanwer)
		{
			cmd.Instantiate(spawner.Prefab, transform.Position, transform.Rotation);
		}.ScheduleParallel();

		Entities.ForEach(TransformAspect transform, in Spawning spanwer)
		{
			EntityManager.Instantiate(spawner.Prefab, transform.Position, transform.Rotation);
		}.Run();
	}
}


Relocate a set of entities with a query

class OffsetWorld : SystemBase
{
	EntityQuery query = GetQuery(typeof(GlobalTranslation));
	
	void OnUpdate()
	{
		// Move the whole world by 1m
		var offset = new float3(1, 0, 0);
		TransformAspect.MoveEntities(query, offset, quaternion.identity);
	}
}

Parenting at runtime

class ParentInstance : SystemBase
{
	void OnUpdate()
	{
		Entity prefab = ...;
		Entity parentEntity = ...;

		var instance = EntityManager.Instantiate(prefab);
		TransformAspect.SetParent(instance, parentEntity, false);
	}
}

Streaming hierarchies in / out has minimal impact on TransformSystem performance

This is still unsolved. Currently we spend a lot of time adding entities to child arrays of their parents at load time. We need to avoid this. Optimally we stream the child arrays in, but there is a lot of complexity here.

  1. scenes can be sectioned. So when transform hierarchies reference across sections, the child array cant be there. Only the parent reference
  2. How do we know during unloading that because we are deleting a hierarchy as part of a big set of entities, that the child array can be deleted because all children were also deleted...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment