Games typically approach this type of issue using a transformation hierarchy.
In this model, each entity can be treated as a "child" of a "parent" entity. The entity's local position and orientation components are interpreted to be relative to the position and orientation of the parent.
Parent entities may in turn have their own parents, grandparents, etc., all the way up to "root" objects that are positioned absolutely in world space.
This lets us build complex articulated structures - like a car with independently spinning/suspended wheels and passengers/cargo that follow it along for the ride, or a humanoid character with a hand that pivots at the wrist of a forearm that pivots at the elbow of an upper arm that pivots at the shoulder... all using one simple transformation composition rule, rather than adding special-case offsets and variants throughout all our components.
Contrary to the concern raised in the comments, this does not need to be unduly unfriendly to the cache. In a simple model, we can associate the following data with each entity, usually in a "transform" component, though it could be subdivided into more granular components:
- a local position
- a local orientation
- a local uniform scale
- a parent entity index (-1 for "this is a root entity")
- a world position
- a world orientation
- a world uniform scale
(these last three could be combined into a world transformation matrix)
Most systems make updates only to the local properties (say, a skeletal animation system rotating each bone's local orientation about its pivot) so they don't need to peek into the hierarchy at all and can do their work strictly on one entity at a time (friendly for parallelization). We can defer updates to the world properties until the next physics or rendering step where we need final positions & such.
If we store our entities in non-decreasing order of parent entity ID (this is not too onerous to maintain, since re-parenting is very rare compared to routine transformation updates), then we can update the whole hierarchy's world properties in one linear scan:
First, we update all the root entities by copying their local parameters to their world parameters
Next, we walk these lists of components with two indices: the current entity we're updating, and the parent index.
Both these indices move strictly forward through the arrays, so we're not jumping back and forth randomly thrashing the cache. In fact we'll often update several entities in a row with the same or adjacent parents, getting excellent cache utilization despite the indirection.
For each of these entities, we update the global properties like so:
parent = parentIndex[current];
worldPosition[current] = worldPosition[parent]
+ worldOrientation[parent] * localPosition[current];
worldOrientation[current] = worldOrientation[parent] * localOrientation[current];
worldScale[current] = worldScale[parent] * localScale[current];
Mat4x4 localTransform = TranslateRotateScale(
worldTransform[current] = worldTransform[parent] * localTransform;
You can even parallelize this work if needed, by dividing your root objects between separate arrays, and placing child objects in the same array as the root. Then each array can have its hierarchy updated independently of the others.
Most interaction between entities in the hierarchy can be accomplished with message systems. So the Torch system doesn't need to directly manipulate the Light Source component on the torch's child entity. It can just leave it a "Turn on" / "Turn off" message when it needs to change state, and the Light system can process that message when it iterates its Light Source components later in the frame.
Now, there will be occasional scripts that do need to reach across to different entities make their decisions and updates. Like an AI awareness system that needs to gather information about nearby entities to update the current entity's state. That's OK though - and largely unavoidable anyway, even with a flat hierarchy. The goal of a data-oriented ECS is not to outright forbid reference-following, it's to keep costly reference-chasing out of the hottest code paths - the things we need to iterate thousands of times per frame.
You can have thousands of animated characters swinging tens of thousands of individual bone transforms around with minimal cache misses, so the less predictable parts of your game scripts - like the player character control logic that only needs to run for a handful of local players each frame - has the breathing room to do its work.
Use the data orientation where it helps you do lots of stuff quickly, but don't let it be a wall that stops you from getting the gameplay behaviour you want.