ECS

Hierarchy & attachment

The two hierarchies meep keeps - ParentEntity for lifetime, TransformAttachment for space - how their systems index and compose them, and how to find a named node on a loaded model.

Meep keeps two hierarchies, and they are deliberately separate. ParentEntity says what an entity’s lifetime is tied to; TransformAttachment says what its transform is relative to. Both are components on the child, each naming its parent by entity id, and they are free to name different entities - which is how a floating health bar can be positioned against a unit’s head while being owned by the UI layer.

Neither direction is stored on the parent. Each hierarchy’s system keeps the reverse index itself, so “who are my children” is a lookup rather than a sweep of the whole dataset.

Child entity[package]ParentEntityTransformAttachmentEntity whose lifetime owns the childEntity the child transform composes against

The two components

ParentEntityTransformAttachment
RelationLifetimeSpatial
Field naming the parententityparent
Extra statenonetransform (the local pose), flags
Owning systemParentEntitySystemTransformAttachmentSystem
When the parent is destroyedthe child is destroyedthe component is removed; the child survives as a root
SerializationOpted out (serializable = false) - entity IDs are transientHas toJSON / fromJSON; no binary adapter is registered by default

Both parent fields are immutable while the component is attached. Writing ParentEntity.entity or TransformAttachment.parent in place does not reparent anything - each system resolves the parent once, when the component links - and it also desynchronises the system’s parent-to-children index, which is built on link and unlink being the only transitions those fields ever undergo. To move an entity, remove the component and add it back.

Spatial hierarchy: TransformAttachment

TransformAttachment holds a local transform and the entity that transform is relative to. TransformAttachmentSystem composes the child’s world Transform64 as parent world times local:

// world transform = parent_transform × attachment.transform
transform.multiplyTransforms(parent_transform, attachment.transform);

Register the system or nothing composes:

import { TransformAttachmentSystem } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachmentSystem.js";

em.addSystem(new TransformAttachmentSystem());   // constructor takes no arguments

Its dependencies are [TransformAttachment, Transform64], so it links any entity carrying both. On link it listens for two entity events - TRANSFORM64_EVENT_CHANGE on the parent, TRANSFORM_ATTACHMENT_EVENT_CHANGE on the child - so motion propagates reactively and a bound child costs nothing per frame. Neither component announces itself, so a hand-written parent move ends with t64_announce_change(ecd, parent) and a hand-written local offset ends with ecd.sendEvent(child, TRANSFORM_ATTACHMENT_EVENT_CHANGE).

Building the pair by hand:

import { TransformAttachment } from "@woosh/meep-engine/src/engine/ecs/transform-attachment/TransformAttachment.js";
import { ParentEntity } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";

const attachment = new TransformAttachment();
attachment.parent = parentEntityId;
attachment.transform.setTranslation(0, 1, 0);   // 1 unit above the parent

new Entity()
    .add(new Transform64())
    .add(attachment)
    .add(ParentEntity.from(parentEntityId))   // also die with the parent
    .build(ecd);

TransformAttachmentFlags.Immediate is set by default and makes the system compose once at link time, so the child’s world transform is correct from frame one. Clear it (attachment.immediate = false) when you know the child’s Transform64 is already right and want to skip the work.

A child whose parent has no Transform64 yet - because it was built first, or because a model has not finished loading - is queued rather than dropped. The system retries up to 32 queued children per update, and finalises each one the moment its parent becomes bindable.

Two hazards worth knowing

Non-uniform parent scale is lossy. The world transform is stored decomposed as translation, rotation and scale. A rotated child of a non-uniformly scaled parent composes to a non-orthogonal basis that TRS cannot hold, and the shear is silently best-fitted away - a mesh under such a parent draws wrong. Nothing in the component fixes this; either scale the parent uniformly, or bake the scale into the geometry.

Deep chains drift. Repeated composition accumulates roughly 1e-6 of scale error over 64 rotation-only levels. Harmless in itself, but the composed child is rewritten and announced on every rotation, so anything listening below it wakes for a scale that did not really change.

Lifetime hierarchy: ParentEntity

ParentEntity names the entity whose destruction destroys this one. ParentEntitySystem owns the cascade and the reverse index:

import { ParentEntitySystem } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntitySystem.js";

em.addSystem(new ParentEntitySystem());

Beyond the cascade it exposes a query surface over the hierarchy:

MethodResult
findParentEntity(entity)The parent, or -1 when the entity has no ParentEntity
findRoot(entity)The top of the chain; entity itself when it has no parent. Throws on a cycle
isAncestorOf(child, ancestor)true if ancestor is on the chain above child. An entity is its own ancestor. Throws on a cycle
findChildrenOf(result, offset, entity)Writes the direct children into result from offset, returns how many
countChildrenOf(entity)Direct child count - sizes a buffer for findChildrenOf without filling one
traverse(root, callback, thisArg?)Visits root and everything below it, parents before children
const parents = em.getSystem(ParentEntitySystem);

const children = [];
const count = parents.findChildrenOf(children, 0, unitEntity);

for (let i = 0; i < count; i++) {
    ecd.removeEntity(children[i]);
}

TransformAttachmentSystem carries the same two lookup methods, findChildrenOf and countChildrenOf, over its own hierarchy.

What the reverse index guarantees

Both systems keep their back-links in an EntityChildIndex (src/engine/ecs/hierarchy/EntityChildIndex.js). It is not a component, and the difference matters:

  • It is derived, never authored. There is no child-list component and nothing user-writable. The owning system is the only writer, the index is never serialized, and it is rebuilt from the child-side components on load. A component would have been a second copy of an edge that already exists, with nothing keeping the two ends agreeing.
  • Children come back ascending by entity id, not in link order. Link order is only as deterministic as entity creation order, and consumers feed traversals whose output order is part of their contract.
  • Callers get a snapshot. findChildrenOf copies out, because the usual thing to do with a child list is destroy or detach what it names - and that would move a live list under the loop walking it. traverse snapshots each node’s children before visiting them for the same reason, so the callback may destroy subtrees it has not reached yet.
  • A key may name an entity that no longer exists, for as long as it takes a removal cascade to reach the children that entity owned. On the spatial side, a queued child is in the index too, so a non-zero child count does not mean those children are composing yet.

ParentEntitySystem.traverse reports a cycle and skips the offending edge rather than throwing, so a caller asking for a subtree still gets the acyclic part of it.

When a parent dies

The two hierarchies answer this differently, and the difference is the reason they are separate components.

A lifetime parent takes its children with it: ParentEntitySystem destroys every entity whose ParentEntity names it, recursively.

A spatial parent does not. When the entity named by TransformAttachment.parent is destroyed there is nothing left to compose against, so the system removes the TransformAttachment component. The child keeps the last world transform it had and becomes a root. That is an observable state change rather than a child silently freezing while holding a dead parent’s Transform64 alive.

If a child should die with its spatial parent - the ordinary case - give it a ParentEntity naming the same entity. The two components together are what make that happen; neither does it alone.

EntityNode - the ergonomic wrapper

EntityNode is a scene-graph helper that manages both components for you: building a child node adds a TransformAttachment and a ParentEntity pointing at its parent, so a node tree behaves the way a scene graph is expected to. It wraps an Entity and exposes a transform property for the local pose.

import { EntityNode } from "@woosh/meep-engine/src/engine/ecs/parent/EntityNode.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";

// Wrap the car's visual entity (already has a Transform64)
const carNode = new EntityNode(carVisualEntity);

// Attach a headlight as a child node
const headlightNode = EntityNode.fromComponents(new Transform64(), headlightComponent);
headlightNode.transform.setTranslation(0.6, 0.4, 1.5);

carNode.addChild(headlightNode);

// Build the whole tree into the dataset at once
carNode.build(ecd);

EntityNode.fromComponents(...components) is a factory that creates a node and adds the supplied components to its underlying entity. A node that has children must carry a Transform64 - build asserts it, because the attachment hierarchy has nothing to compose against otherwise.

EntityNode key members:

MemberDescription
transformLocal Transform64. After writing it, call transform_changed() - the node pushes the pose into the components and announces it; nothing watches the buffer for you
entityThe underlying Entity builder
parentParent EntityNode, or null for a root
childrenRead-only array of child EntityNode instances
addChild(node)Attaches node as a child; builds it immediately if the parent is already built
removeChild(node)Detaches a child
traverse(visitor)Depth-first visit of this node and all descendants
traverseChildren(visitor)Depth-first visit of the descendants only
rootWalks up to the root of the hierarchy
build(ecd)Builds this node and all children into the dataset
destroy()Destroys all children first, then this entity
isBuilttrue after build()

This pattern is used in the raycast-vehicle example to attach wheels and lights to the car chassis. The physics body is a separate entity; only the visual mesh rides the EntityNode tree.

Finding a named node on a model

There are no attachment-socket components, and no skeleton object to ask for a bone. A model expanded into entities - with shade_bundle_to_entity_composition, which builds an EntityNode tree - gives every node of the asset an entity carrying a Name and a TransformAttachment pointing at its parent. Naming a bone is naming an entity in a hierarchy.

import { transform_attachment_find_descendant_by_name }
    from "@woosh/meep-engine/src/engine/ecs/transform-attachment/transform_attachment_find_descendant_by_name.js";
import { transform_attachment_parent_of }
    from "@woosh/meep-engine/src/engine/ecs/transform-attachment/transform_attachment_parent_of.js";

const hand = transform_attachment_find_descendant_by_name(ecd, characterEntity, "hand.R");
// -1 when nothing under `characterEntity` carries that name.
// That is not an error - the model may simply not have arrived yet.

const up_one = transform_attachment_parent_of(ecd, hand);   // -1 when `hand` is a root

transform_attachment_find_descendant_by_name searches names first and ancestry second, which is the cheaper way around: Name is a component the dataset already indexes, and the walk up from a candidate is the depth of the hierarchy rather than its width. The root entity is not itself a candidate.

Anything you attach to that entity - with its own TransformAttachment and ParentEntity - now rides the bone.

Where the node actually is this frame

The entity’s Transform64 is only current for hierarchies the CPU composes. A joint driven by a skinned animation is posed on the GPU, so the entity that carries its name still holds the rest pose. Ask for the animated pose instead - this is a different lookup: it goes to MeshSystem for the entity’s loaded model instance and finds the node by its asset name, rather than finding an entity in the dataset.

import { query_entity_node_world_pose }
    from "@woosh/meep-engine/src/engine/graphics3/pose/query_entity_node_world_pose.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";

const pose = new Transform64();

if (query_entity_node_world_pose(pose, engine.entityManager, characterEntity, "hand.R")) {
    // pose carries both its matrix and its components
}

This is the sanctioned way to ask where a socket, muzzle or effect anchor is. The answer is computed, never read back from the GPU: readback is a frame late and asynchronous, so an outcome that depended on it would depend on frame pacing, and determinism and replay would go with it. The pose is evaluated over exactly the clips the animation systems are driving, off the same clock the renderer draws from, so a socket stays on the mesh you can see.

It returns false when there is no such node to answer about - no MeshSystem registered, the model has not loaded, the entity has no model, or the name is not in it. None of the four is an error, so branch on the return value rather than assuming a pose.

Where to go next