What combinations of components make a "Clip", a "Screen", a "Projector"? That contract isn't enforced by the registry — EnTT will happily attach any component to any entity — but the rest of the codebase assumes specific shapes. This file is the inventory.
If you're adding a new archetype, document it here. If you're touching one, keep this file in sync.
- Required components are assumed present by at least one system. The archetype doesn't function without them.
- Optional components add capability. The archetype works without them.
- Invariant is the runtime contract the systems rely on.
- Created at lists the canonical creation sites — usually one or two places.
Layer is the component that says "this entity lives on a TimelineTrack."
Every timeline-resident archetype (Clip, ObjectAnimation, future Generative)
carries a Layer alongside its kind-specific data components. Single
contract — start frame, duration, owning track, label, color, Kind enum —
shared across kinds. See include/entity/components/Layer.hpp.
Kinds shipped with the abstraction:
Kind::Clip— paired with theCliparchetype belowKind::ObjectAnimation— paired withObjectAnimationLayer(Phase 3, see below)Kind::Generative— reserved for future generative layers
Phase 3 migration window: for Clip-backed layer entities,
Clip::startFrame / Clip::duration remain the source of truth and the
Layer mirror is synced via syncLayerFromClip(). Promotion of those
fields onto Layer outright is deferred to a Phase 4 cleanup PR.
Composition, not inheritance: systems select by component
combination (view<Layer, Clip> vs. view<Layer, ObjectAnimationLayer>),
never by reading Layer::kind at runtime. The Kind enum exists for the
UI badge, serialization disambiguation, and human-readable logs.
Created at: commit 3.1 introduces the component. Retroattach onto
existing Clip entities lands in commit 3.2.
| Required | Optional |
|---|---|
Clip |
MediaLayer |
Transform |
AnimatedProperties |
ClipDecodeState |
ClipPlaybackPhase |
VideoTexture |
FrameBuffer (transitional tag) |
Invariant: startFrame ≤ currentFrame < startFrame + duration (in
timeline frames) → clip is active and will appear in SceneSnapshot::activeClips.
Content-layer principle (ADR-0018): Clip is one instance of the
broader content-layer archetype — Layer + Transform + MediaLayer + one kind-specific component that contributes a texture. The kind-specific
piece for Clip is Clip + VideoTexture (decoder produces a texture in a
video-pool slot); for Generative it's GenerativeLayer + <kind state> (PASS 1 procedural draw populates a compose-target slot).
CompositorSystem PASS 2 is kind-blind — it walks
RenderFrame::contentLayers regardless of which kind produced the
texture.
Created at:
src/timeline/Timeline.cpp(drag-onto-timeline, split, duplicate)src/project/ProjectSerializer.cpp(project load)src/core/Engine.cpp(assorted command handlers and demo paths)
Notes:
Transformis a separate component (not embedded inClip) precisely becauseCompositorSystemiteratesview<Transform, MediaLayer, Clip>()— keeping Transform separate keeps the dense iteration efficient.MediaLayeris technically optional but in practice every active clip has one (z-order + opacity + blend mode). Treat as required for new code.AnimatedPropertiesis attached only when the user adds at least one keyframe. Absent for static clips.ClipPlaybackPhaseis allocated lazily bySectionSchedulerat the first section break.FrameBufferis currently a marker tag —Clipalone is sufficient in practice. Likely to disappear in a future cleanup.
| Required | Optional |
|---|---|
Layer (Kind::ObjectAnimation) |
AnimatedProperties |
ObjectAnimationLayer |
ObjectAnimationOutput |
Invariant: Layer::startFrame ≤ currentFrame < Layer::startFrame + Layer::duration
→ the layer is active and AnimationSystem evaluates its keyframe tracks,
writing results into ObjectAnimationOutput. buildSceneSnapshot folds
ObjectAnimationOutput into the target screen's ScreenSnapshot entry
(Phase 3.4). The baked ObjectAnimationLayerSnapshot in bus::SceneSnapshot
lets the show thread re-evaluate tracks per render frame during editor stalls
(Phase 3.7, mirror of the NEW-07 clip-animation fix).
Created at:
src/core/Engine.cpp(createObjectAnimationLayer) — used byCreateObjectAnimationLayerCommandand the LayersWindow drag-to-timeline UI.src/project/ProjectSerializer.cpp(project load, schema v15).
Notes:
ObjectAnimationLayer::targetis theScreenorPropentity being driven. One OA layer → one target entity per layer. Multiple OA layers on different tracks may target the same screen;buildSceneSnapshotfolds them all into the target'sScreenSnapshot(last-write-wins per field, ordered by track index).AnimatedPropertiesis optional — absent when the layer has no keyframes.AnimationSystemskips the entity if!animProps.hasAnyKeyframes().ObjectAnimationOutputis emplace'd lazily byAnimationSystemon the first active tick. Cleared to default on every tick the layer is active, then repopulated from the evaluated tracks. After-end behavior depends onendBehavior(see next bullet). Always reset before the layer's start frame (no last-evaluated value exists to hold).ObjectAnimationLayer::endBehavior(ADR-0020) —Hold(default) keepsObjectAnimationOutputpopulated past the layer's active window so the target stays parked where the animation left it.Resetclears the output on the first frame paststartFrame + duration, falling back to the Stage-configured base position. Persisted in.entityschema v17; pre-v17 OA layers load withHold. Bake site:PlaybackTimeAuthority::buildSceneSnapshotfilters after-end-Reset layers out ofbus::SceneSnapshot::objectAnimationLayersentirely; after-end-Hold layers ride the bus so the show thread re-eval keeps applying them during editor stalls.ObjectAnimationLayer::sectionBehaviormirrors the same enum used byClip(ADR-0012 / ADR-0016).Lockedlayers freeze at a section break (frozen = trueset bySectionScheduler::seedContinuationAt; cleared byclearAllContinuationon GO / Stop).Normallayers continue evaluating. Independent ofendBehavior— section-freeze and end-of-layer Hold are separate concerns.- Animatable channels (per
AnimatablePropertyenum):PositionX/Y/Z,RotationX/Y/Z,ScaleX/Y/Z(9 axes). The legacyRotationenum value maps to Y-axis rotation on OA layers — kept for back-compat with pre-ADR-0020 projects; new authoring usesRotationZfor roll explicitly. - This archetype is not included in
SceneSnapshot::activeClipsand is never visible toCompositorSystemdirectly. The compositor only sees the downstream effect via screen position/rotation/scale in the render frame.
| Required | Optional |
|---|---|
Layer (Kind::Generative) |
— |
GenerativeLayer |
|
Transform |
|
MediaLayer |
|
<kind-specific state> (e.g. MunchersGameState, TextLayerState) |
Invariant: Layer::startFrame <= currentFrame < Layer::startFrame + Layer::duration
→ the layer is active. GenerativeSystem ticks the kind-specific state
component (MunchersGameState for Muncher; TextLayerState dirty-flag triggers
TextSystem rasterization). On the show thread, CompositorSystem PASS 1 renders
the procedural content into the layer's own compose target (allocated lazily via
the same R2D-ack pattern Screen uses); PASS 2 then composites that texture onto the
target screen via drawTexturedQuad(layerRT, transformMatrix, opacity, blendMode, ...).
Sub-kind dispatch is by component composition (ADR-0016 / 0017 / 0018):
| Kind-specific component | Sub-kind | Ticked by | Persistence |
|---|---|---|---|
MunchersGameState |
Muncher | GenerativeSystem |
schema v21 sub_kind="muncher" |
TextLayerState |
Text | TextSystem (dirty-flag rasterize) |
schema v21 sub_kind="text" + text_state object |
| (none) | (reserved) | — | — |
Presence of the kind-specific component is the sole discriminator —
there is no Kind-enum field on the entity for runtime dispatch.
The compositor's PASS 2 is kind-blind: it only reads the unified
ContentLayerSnapshot. See ADR-0018.
Created at:
src/core/Engine.cpp(createMuncherLayer,createTextLayer) — used byCreateMuncherLayerCommand/CreateTextLayerCommandand the LayersWindow drag-sources.src/project/ProjectSerializer.cpp(project load, schema v21 generative branch).
Notes:
GenerativeLayer::targetScreenroutes the produced texture to a single screen (entt::nullmeans "no target — output dropped" per the PropertyWindow warning).GenerativeLayer::renderTargetSlotis-1until CompositorSystem PASS 1 allocates a compose target and the R2D ack (GenerativeLayerRenderTargetAllocated) writes the slot back on the editor thread. For Text layers,TextLayerState::textureSlotis the video- pool descriptor slot;TextSystem::onTextLayerDestroyedfrees it on entity destruction.Transformis the layer's UV-space transform in the target screen's NDC, same semantics as Clip —scale=1fills the screen,position=(0,0)centers it. ADR-0018.MunchersGameStateis ~120 bytes — exceeds the components/CLAUDE.md <64-byte soft rule, documented exception in ADR-0017 (one Muncher per layer, not iterated in a tight view).TextLayerStateholds std::string members (text, fontFamily) — same soft-rule exception asMunchersGameState. One per layer, never in a hot-path view.dirty=truetriggers a re-rasterize on the nextTextSystem::updatetick.- Delete / copy / paste of Generative layers is fully supported as of
Phase 6.
Timeline::snapshotClipForDeletecaptures the fullGenerativeLayer- Transform + MediaLayer + kind-specific state into
DeletedClipSnapshot.restoreDeletedCliprebuilds the entity from the snapshot with a freshrenderTargetSlot=-1andtextureSlot=-1so the PASS 1 / TextSystem allocators re-acquire resources cleanly. Copy/paste goes throughEngine::snapshotClipForClipboard/materializeClipFromSnapshot— the same path as Clip, with EffectChain deep-clone and ContentRoutingRef shallow-copy applied to both kinds from a shared tail inmaterializeClipFromSnapshot. OA layers are intentionally excluded from copy/paste (entity-ID cross-session validity).
- Transform + MediaLayer + kind-specific state into
| Required | Optional |
|---|---|
Effect |
EffectAnimatedParameters |
EffectParameters |
Invariant: an effect entity exists iff its entt::entity is
referenced in some layer's EffectChain.nodes. Orphaned effect
entities (no chain references them) are leaks — the
RemoveEffectCommand always destroys both the entity and its slot
in the chain. PASS 1.5 of the show-side compositor (ADR-0019) walks
each layer's EffectChain and dispatches drawEffectPass per
enabled effect.
Created at:
src/command/Commands.cpp(AddEffectCommand::execute)src/project/ProjectSerializer.cpp(deserializeEffectChainon project load)
Notes:
- Effects are entities — they're not stored as
vector<EffectInstance>insideEffectChainbecause each kind's parameters live on its own components, animatable via the existing keyframe infrastructure on the siblingEffectAnimatedParameterscomponent. Effect::kindIdis the FNV-1a hash of the kind's stable string ID (e.g.fnv1a32("core.gaussian_blur")). Engine effects register the same hash every startup — wire-stable across builds.EffectParameters::valuesis positional, indexed by the kind'sParamSchemaslot order. Phase 6 user-effects must preserve schema order on hot-reload or saved projects re-bind to wrong slots.EffectAnimatedParameters::tracksare hash-keyed (FNV-1a of param name) — separate fromAnimatedPropertieswhose closed enum can't represent open-ended effect-param sets. Both components can coexist on the same effect entity if the effect's params are animated.
| Required | Optional |
|---|---|
EffectChain |
EffectChainRenderTargets |
Attached to the layer entity (Clip, Generative, future content kinds).
Holds the ordered list of effect-entity IDs (nodes), the explicit
graph topology for the node editor (connections, populated in
Phase 4 — empty in v1), and the synthetic output socket pointer
(outputNode).
Invariant: When connections is empty, evaluation order is
nodes order (linear stack). When connections is non-empty, the
chain is topologically sorted with outputNode as the chain sink.
EffectChainRenderTargets is lazily allocated by PASS 1.5 (R2D ack
writes the two ping-pong slot IDs).
Created at:
src/command/Commands.cpp(AddEffectCommandcreates the component on first effect added to a layer)src/project/ProjectSerializer.cpp(project load)
Notes: Layer entities without an EffectChain are unaffected —
PASS 1.5 short-circuits on empty effects in
ContentLayerSnapshot.effects. Adding an empty EffectChain
component is benign but pointless.
| Required | Optional |
|---|---|
Screen |
— |
Invariant: A Screen is a render-target source for an OutputDisplay.
Screen.modelEntity references a separate Model entity for geometry.
Screen.visible == true and renderTargetValid == true → eligible for
output routing.
Created at:
src/core/Engine.cpp(Stage UI "Add Screen" command + script command)- Test / project-load paths
Notes:
- Screens carry their own
position/rotation/scalearrays inside theScreenstruct rather than using a separateTransformcomponent. Different convention from Clip — see "Positional state inconsistency" at the bottom. - The compose target slot (
renderTargetSlot) is assigned byCompositorSystemvia the snapshot-ack roundtrip (see ADR-0014).
| Required | Optional |
|---|---|
Projector |
— |
Invariant: enabled == true AND linkedOutput references a live
OutputDisplay entity → projector renders to that output. With
targetSurfaceCount == 0 it illuminates all ProjectionSurface screens;
non-zero restricts to the listed surface entities.
Created at:
src/render/OutputManager.cppand projector UI
Notes:
- Carries own positional state (position / rotation / fovDegrees / nearClip / farClip), same convention as Screen / Prop.
calibrationPointsis an unboundedstd::vectorinside the component. Acceptable because there are few projectors (typically <10) and calibration points are bounded by user workflow (~16-50).- Calibration math is in ADR-0011 — touch carefully.
| Required | Optional |
|---|---|
Prop |
— |
Invariant: Props are editor-only. They're explicitly excluded from
SceneSnapshot::screens and the show thread never sees them. Per ADR-0014,
if/when projector masking lands, it'll add a separate propCatalog rather
than fold props into screens.
Created at:
src/ui/StageWindow.cpp(Add Prop command)
Notes:
- References a
Modelentity for shared geometry (one chair.obj, five placements). - Same own-positional-state convention as Screen / Projector.
| Required | Optional |
|---|---|
OutputDisplay |
— |
Invariant: enabled == true AND (sourceProjector != null OR
sourceScreen != null) → this output renders something.
sourceProjector takes priority over sourceScreen when both are set.
Created at:
src/render/OutputManager.cppon display enumeration- Project load (persisted output assignments)
Notes:
- Big struct (~450 bytes) because it bundles display identification, OCIO state, calibration overlay state, and window state. Acceptable: there are tens of outputs at most, not thousands, and it isn't iterated in a per-frame hot path.
- Carries
CalibrationOverlaysub-struct (runtime-only, not persisted).
| Required | Optional |
|---|---|
OutputSurface |
— |
Invariant: visible == true → drawn by renderOutputSurfaces. Its
outputIndex ties it to a specific OutputDisplay for raster routing.
Renamed from MappingSurface in Phase M1 of the two-tier mapping work
(ADR-0021). The struct is Plane B — a per-output warp quad — and never
participated in Plane A content routing despite the old name's
implication.
Created at:
src/ui/OutputsWindow.cpp
Notes:
- Self-contained quad + soft edges + source-region UV state. Carries
several geometric helpers (
translate,scale,containsPoint,findNearestCorner) that touch only own fields — accepted under the components soft-rule. SeeECS_PRINCIPLES.md.
| Required | Optional |
|---|---|
Model |
— |
Invariant: A Model entity exists per unique mesh asset. Multiple
Screen and Prop entities can reference the same Model via their
modelEntity field. The mesh data + GPU upload slots live on the Model.
Created at:
- Model-import paths in
src/render/
| Required | Optional |
|---|---|
Camera |
— |
Invariant: Single Camera entity per editor 3D viewport (currently
just one — the Stage window). Drives Stage3DRenderer's view + projection
matrices.
Created at:
src/render/Stage3DRenderer.cpp(single registry-emplace at startup)
Notes:
- Currently a single Camera entity in the editor. If we ever add multiple 3D viewports (split view, etc.), we'd need to disambiguate via a tag or a Camera-active flag.
- Carries
MIN_PITCH/MAX_PITCH/MIN_DISTANCE/MAX_DISTANCEconstants for orbit clamping — those are camera-system policy, not per-entity data, but kept on the component for locality.
ADR-0021 introduced ContentRouting as a per-layer inline component
with Direct + Tiled modes. ADR-0022 promotes that data to a library
of ContentRoutingAsset entities that Clip / GenerativeLayer
reference via ContentRoutingRef, and adds a third authoring kind,
Feed Map.
| Required | Optional |
|---|---|
ContentRoutingAsset |
— |
Invariant: the library is editor-thread-only. Multiple Clip /
GenerativeLayer entities can reference the same asset via
ContentRoutingRef. The asset's kind is one of Direct / Tiled /
FeedMap; targets is the materialized route list (the truth — Tiled
authoring metadata tiledCount / tiledAxis is wizard state, not
the snapshot source). When autoBoundScreen != entt::null the asset
is the auto-direct routing for that Screen and RoutingLibrarySystem
keeps name in sync until the user manually diverges it
(name != lastSyncedScreenName indicates "autosync broken").
Created at:
src/systems/RoutingLibrarySystem.cpp— auto-direct entries created per Screen on each reconcile tick.src/ui/ContentRoutingWindow.cpp— user "+ Add" creates Direct / Tiled / Feed Map entries.src/project/ProjectSerializer.cpp— loaded from thecontentRoutingAssetstop-level array (v20+); v19 migration spins up "Custom Routing N" assets per unique inline ContentRouting shape.
Notes:
- Soft-rule exception: carries
std::string+std::vector. Library assets are never iterated in a per-frame view, so the heap cost is irrelevant. Documented exception alongsideClip/Transform. - Snapshot bake (
PlaybackTimeAuthority::buildSceneSnapshot) reads the asset only on the editor thread and copies the resolvedtargetsintobus::ContentLayerSnapshot.routes— the show thread never sees the asset itself.
Attached to a Clip or GenerativeLayer entity alongside its other
components. asset == entt::null is the "render on all visible
screens" semantic (equivalent to a pre-ADR-0022 empty-targets inline
ContentRouting).
Created at:
src/ui/PropertyWindow.cpp(Content Routing dropdown).src/command/Commands.cpp(applyClipTargetScreen,applyContentRoutingSpec).src/project/ProjectSerializer.cpp(migration from v19 inlineContentRouting).
Notes: dangling refs (asset destroyed without
RoutingLibrarySystem clearing them) are tolerated by the bake site —
treated as null. The system handles cascade-clear on Screen destroy.
Clip::targetScreen and GenerativeLayer::targetScreen are kept
readable for one project-format version (ADR-0021's backward-compat
window) and are removed in v21.
The previously-declared FeedMapping struct (Screen.hpp, dead code)
was removed in ADR-0021 M1; its name conflated Plane A with Plane B
in the industry vocabulary. The Plane A replacement is the
ContentRoutingAsset library introduced by ADR-0022.
Two patterns exist for how entities encode position:
-
Separate
Transformcomponent: Used byClip. Reason: enablesview<Transform, MediaLayer, Clip>()packed iteration inCompositorSystem. -
Embedded position/rotation/scale arrays: Used by
Screen,Projector,Prop. Reason: these aren't iterated in the per-frame compositor view; only one of each is touched per frame at most.
Both are defensible. We're not unifying because the cost (move ~6 fields per archetype, audit ~50 call sites) doesn't buy a property that matters today. If a future feature requires iterating Screens or Props at high frequency, revisit.
docs/reference/ECS_PRINCIPLES.md— the rules these archetypes followdocs/reference/SYSTEM_ORDERING.md— which systems read/write these archetypes per framedocs/adr/0014-editor-show-thread-split.md— why some archetypes are excluded from the show-thread snapshotinclude/entity/components/CLAUDE.md— operator-facing rule sheet