Stage Pipeline

The composable per-tick pipeline each Phantom Camera runs — Body, Aim, Reposition additives, Noise additives. Stage interfaces, CameraState accumulator, target routing, decoupled-ideal pattern, init contract.

Every Phantom Camera runs the same fixed pipeline per tick:

Body → Aim → Reposition additives → Noise additives → Finalize

The Body and Aim stages produce the cam’s smoothed ideal pose; Reposition additives correct it for collision / tug volumes; Noise additives perturb it for shake and impulse; Finalize commits the result to the entity transform and the engine camera component.

Stages are not AZ::Components. They are reflected polymorphic types — derived from IBodyStage, IAimStage, or IAdditiveStage. The editor’s type-picker enumerates registered derivations from the SerializeContext class hierarchy, so other gems can extend the catalog cleanly without touching the base component.

For per-variant detail, see the catalog pages:

  • Body Stage VariantsDefaultFollowBody, OrbitBody, DynamicOrbitBody, LeadingFollowBody, TrackBody.
  • Aim Stage VariantsDefaultLookAtAim, ClampedLookAim. Plus extensions from gs_performer.
  • Additive Stage VariantsCollisionReposition, OcclusionReposition, TugAimListener, TugBodyListener, PerlinNoise, ImpulseNoise.

 

Contents


Per-Tick Model

Each tick, the owning Phantom Camera threads a CameraState accumulator through the pipeline:

GS_PhantomCameraComponent::EvaluatePose
   ├─► Build CameraContext (target, deltaTime, lifecycle flags, snapThisFrame)
   ├─► CameraState state;  // zero-initialized accumulator
   ├─► IBodyStage::Evaluate(state, ctx, dt)
   │       writes state.position, state.bodyAnchor
   ├─► IAimStage::Evaluate(state, ctx, dt)
   │       writes state.rotation, state.lookAtPoint
   ├─► m_desiredPose = (state.position, state.rotation)
   ├─► For each additive in m_repositionStages:
   │       Evaluate(state, ctx, dt) — may correct state.position/rotation
   ├─► m_stablePose = (state.position, state.rotation)
   ├─► For each additive in m_noiseStages:
   │       Evaluate(state, ctx, dt) — perturbs state.position/rotation
   ├─► m_finalPose = (state.position, state.rotation)
   ├─► Resolve cached pivot from state.lookAtPoint (else fallback to target world pos)
   └─► Commit m_finalPose to TransformBus + apply lens to engine Camera component

The component partitions additives into m_repositionStages and m_noiseStages once at slot-assign time (or whenever the additive list changes) based on each additive’s GetStage() result — per-tick code does not re-check the enum each frame.

CamPipelineStage is a fixed ordered enum:

enum class CamPipelineStage {
    Body,
    Aim,
    Reposition,
    Noise,
    Finalize
};

Reposition and Noise are the values an additive may return from GetStage. Body, Aim, and Finalize exist for symmetry but are not author-selectable.


Stage Interfaces

IBodyStage

Single slot per cam. Computes the smoothed follow position.

class IBodyStage {
public:
    AZ_RTTI(IBodyStage, "{6A78E21A-9A5F-4AB5-8F20-08B4D09E5C01}");
    AZ_CLASS_ALLOCATOR(IBodyStage, AZ::SystemAllocator);

    virtual ~IBodyStage() = default;

    virtual void Init() {}

    virtual void Evaluate(CameraState& state, const CameraContext& ctx, float dt) = 0;
    virtual AZ::EntityId ResolveTarget(AZ::EntityId incoming, TargetSignalKind kind) = 0;
    virtual const char* GetDisplayName() const { return "Body Stage"; }

    // State inheritance — default returns false (no protocol).
    virtual bool TryGetPoseSnapshot(CamPoseSnapshot& out) const  { return false; }
    virtual bool TryAdoptPoseSnapshot(const CamPoseSnapshot& in) { return false; }

    static void Reflect(AZ::ReflectContext* context);
};

See Body Stage Variants for the catalog.

IAimStage

Single slot per cam. Computes the smoothed look rotation.

class IAimStage {
public:
    AZ_RTTI(IAimStage, "{9F37E4C6-04AD-4B1F-A9E2-0D3A4B8F1E02}");
    AZ_CLASS_ALLOCATOR(IAimStage, AZ::SystemAllocator);

    virtual ~IAimStage() = default;

    virtual void Init() {}

    virtual void Evaluate(CameraState& state, const CameraContext& ctx, float dt) = 0;
    virtual AZ::EntityId ResolveTarget(AZ::EntityId incoming, TargetSignalKind kind) = 0;
    virtual const char* GetDisplayName() const { return "Aim Stage"; }

    static void Reflect(AZ::ReflectContext* context);
};

Aim does not implement state inheritance — inheritance is body-only by design. See Aim Stage Variants.

IAdditiveStage

Stackable. Self-declares phase via GetStage.

class IAdditiveStage {
public:
    AZ_RTTI(IAdditiveStage, "{B7D2F618-3E44-4A71-9C21-0F3B7C5E0D04}");
    AZ_CLASS_ALLOCATOR(IAdditiveStage, AZ::SystemAllocator);

    virtual ~IAdditiveStage() = default;

    virtual void Init() {}

    virtual CamPipelineStage GetStage() const = 0;  // returns Reposition or Noise
    virtual void Evaluate(CameraState& state, const CameraContext& ctx, float dt) = 0;
    virtual const char* GetDisplayName() const { return "Additive Stage"; }

    static void Reflect(AZ::ReflectContext* context);
};

See Additive Stage Variants.


CameraState and CameraContext

CameraState

The mutable pose accumulator threaded through every stage. Not serialized.

struct CameraState {
    AZ::Vector3    position    = Vector3::Zero();        // accumulator
    AZ::Quaternion rotation    = Quaternion::Identity();

    // Sidecar hints populated by Body/Aim for downstream stages.
    AZ::Vector3    bodyAnchor  = Vector3::Zero();   // post-offset follow anchor
    AZ::Vector3    lookAtPoint = Vector3::Zero();   // world-space focal point
};

The two sidecar fields exist so Reposition additives can read the “natural” pose Body / Aim intended rather than the live state (which may have been displaced by an earlier additive). Tug listeners, for example, blend toward the source point starting from the Body’s bodyAnchor rather than the cam’s possibly-perturbed state.position — prevents compound feedback across ticks.

CameraContext

Read-only per-tick context. Not serialized.

struct CameraContext {
    GS_PhantomCameraComponent* owner;        // for owner helpers (WriteLensFieldOfView, QueueSnapCamera).

    AZ::EntityId               camEntityId;
    AZ::EntityId               targetEntityId;

    AZ::Transform              camInitialTM;     // cam TM read at start of tick
    AZ::Transform              targetTM;         // resolved target world TM
    AZ::Vector3                targetVelocity;

    bool                       targetIsPhysical;
    bool                       hasFocus;
    bool                       alwaysUpdate;
    bool                       blendingOut;
    bool                       snapThisFrame;    // bypass damping this tick

    float                      deltaTime;
};

hasFocus || alwaysUpdate || blendingOut is the input-integration gate — Body stages that drive pose from input (notably DynamicOrbitBody) integrate input only when any of these is true. See Execution States.

snapThisFrame is set on focus gain, target swap, queued snap, and a few other lifecycle events. Stages honor it by bypassing damping for the one tick it is true.


Target Routing

Every Body and Aim variant authors a CamTargetMode enum and (depending on mode) supporting fields. This is the front-end every variant uses to decide which entity it actually follows.

enum class CamTargetMode : AZ::u8 {
    None               = 0,  // no-op for target resolution
    Transform          = 1,  // follow / look at the dispatched CameraTarget
    TargetOffsetEntity = 2,  // reserved — not yet implemented
    // 3 was FocusGroup — removed; use GroupTarget.
    Head               = 4,  // reserved — not yet implemented
    Override           = 5,  // manually-set m_overrideEntity on this stage
    GroupTarget        = 6   // resolve a named GroupTargetComponent via CamManager
};

TargetSignalKind identifies which bus call dispatched the target signal. Lets stages filter by target mode:

enum class TargetSignalKind {
    CameraTarget,  // SetCameraTarget
    FocusGroup     // SetTargetFocusGroup
};

Legacy scenes storing FocusGroup = 3 deserialize as an unknown value; the author re-picks GroupTarget. The newer mode is the canonical way for a stage to track a named group via Group Targets.


Decoupled Ideal Pattern

A subtle but critical convention: each Body / Aim variant maintains an m_idealPosition / m_idealRotation independent of what gets committed to the entity transform.

Why: Reposition additives (collision, occlusion, tug) and Noise additives mutate state.position and state.rotation AFTER Body / Aim runs. If the Body read the committed transform back next tick, the spring would re-try its advance every frame against a collision clamp — producing shudder. By tracking an internal ideal, the Body’s spring runs in clean kinematic space; the committed transform may differ (clamped by collision, shaken by noise) but the Body’s trajectory stays smooth.

The same applies to Aim — m_idealRotation is the slerp’s working value; downstream rotation-modifying additives can freely displace state.rotation without poisoning next tick’s slerp trajectory.

m_idealPosition / m_idealRotation are also what state inheritance reads for TryGetPoseSnapshot and writes for TryAdoptPoseSnapshot — see State Inheritance.


Damping Convention

There is no separate Smoothing stage. Each stage owns its damping internally:

StageWhat it damps
BodyPursuit of the follow target — halflife on m_idealPosition.
AimSlerp toward the look-at rotation — halflife on m_idealRotation.
Reposition additivesTheir own correction delta — collision pushback, tug-source approach.
Noise additivesOperate in their own time domain (noise time, ADSR envelope).

Different halflives capture different physical slownesses and compose naturally. Damping uses the frame-rate-independent HalflifeAlpha(halflife, dt) = 1 - exp2(-dt / halflife) helper.

ctx.deltaTime is clamped at tick entry (maxEvalDeltaTime = 0.05s default) so editor hitches don’t spike damping.


Init Contract

Each stage’s Init() runs once per Activate, after the slot is populated, before any Evaluate. The default is a no-op.

Critical use case — asset-fresh-load. Stages holding asset references (e.g. DynamicOrbitBody.m_orbitShape, PerlinNoise.m_profile) override Init to force a fresh AssetManager load. The deserialized asset reference only carries an AssetId — without an explicit GetAsset call in Init, in-editor edits to the asset don’t propagate to the runtime instance.

Pattern:

void DynamicOrbitBody::Init()
{
    if (m_orbitShape.GetId().IsValid())
    {
        m_orbitShape = AZ::Data::AssetManager::Instance().GetAsset<CameraOrbitShape>(
            m_orbitShape.GetId(),
            AZ::Data::AssetLoadBehavior::PreLoad);
        m_orbitShape.BlockUntilLoadComplete();
    }
}

Any new stage you author that holds an AZ::Data::Asset<T> should follow this pattern.


Polymorphic-Array Convention

The component’s stage slots hold raw pointers in a vector:

AZStd::vector<IBodyStage*>     m_bodySlot;     // single-element conceptually
AZStd::vector<IAimStage*>      m_aimSlot;      // single-element conceptually
AZStd::vector<IAdditiveStage*> m_additives;

Why vectors instead of single pointers? The vector enables the editor’s type-picker for polymorphic types — the user picks from a dropdown of all registered derived classes. The runtime treats Body and Aim slots as single-element; the dropdown for those slots is constrained to one entry by policy.

The component owns the pointers (newed by the editor’s type instantiation; deleted by the component’s Deactivate / destructor).


StageHelpers Utility

StageHelpers.h provides free inline functions used by multiple variants:

HelperPurpose
ResolveByMode(mode, incoming, override, kind, groupTargetName)Resolves the actual tracked entity from a CamTargetMode. Shared by every Body and Aim variant.
ResolveStageTargetTM(mode, groupTargetName, ctx)Resolves the target world TM. Handles Transform (uses ctx.targetTM), Override (looks up m_overrideEntity), GroupTarget (calls Cam Manager’s group registry).
ApplyFollowOffset(point, targetTM, offset, isRelative)Adds the offset to the point. World axes or target-relative basis.
HalflifeAlpha(halflife, deltaTime)Frame-rate-independent damping alpha: 1 - exp2(-deltaTime / halflife).
WrapYawToPi(yaw)Wraps yaw into (-π, π].
ShortestYawTarget(currentYaw, desiredYaw)Returns the equivalent of desiredYaw that is numerically closest to currentYaw (avoids 2π wraparound jumps for monotonic damping).
FindPitchOnShape(shape, radiusObserved, heightObserved)Inverse search on a CameraOrbitShape — given an observed (radius, height) pair, returns the pitch that produces that point on the curve. Used by DynamicOrbitBody’s POSITION-mode adoption fallback.

Reflection Conventions

Every stage class follows the same Reflect pattern:

void MyStage::Reflect(AZ::ReflectContext* context)
{
    if (auto* sc = azrtti_cast<AZ::SerializeContext*>(context))
    {
        if (sc->FindClassData(azrtti_typeid<MyStage>())) { return; }  // guard

        sc->Class<MyStage, IBodyStage>()   // base = IBodyStage / IAimStage / IAdditiveStage
            ->Version(1)
            ->Field("TargetMode", &MyStage::m_targetMode)
            ->Field("Offset",     &MyStage::m_offset)
            // ...
            ;

        if (auto* ec = sc->GetEditContext())
        {
            ec->Class<MyStage>("Display Name", "Tooltip description")
                ->ClassElement(AZ::Edit::ClassElements::EditorData, "")
                    ->Attribute(AZ::Edit::Attributes::AutoExpand, true)
                    ->Attribute(AZ::Edit::Attributes::NameLabelOverride, "<h3>My Stage</h3>")
                ->DataElement(AZ::Edit::UIHandlers::ComboBox, &MyStage::m_targetMode, "Target Mode", "...")
                    ->EnumAttribute(CamTargetMode::Transform, "Transform")
                    // ...
                ;
        }
    }
}

Hard rules for stage authors:

  • The FindClassData guard is mandatory. Multiple stages reflect the shared curve type / common enums chain, and an unguarded reflect call triggers a double-registration crash.
  • EnableForAssetEditor belongs under SerializeContext only, not EditContext. Stages don’t typically need this attribute, but it applies when reflecting asset types.
  • Members are named with the m_ prefix per the O3DE code-style guideline; field strings inside Reflect keep the un-prefixed form for save-data compatibility.

See Also

Stage variant catalogs:

For related PhantomCam pages:

  • Phantom Cameras — the owning component.
  • Cam Core — consumes published poses.
  • State Inheritance — the per-Body TryGet/AdoptPoseSnapshot protocol.
  • Orbit Profiles.camorbit asset consumed by DynamicOrbitBody.
  • Noise Profiles.camnoiseprofile asset consumed by PerlinNoise / ImpulseNoise.
  • Tug Fields — volume / source / proxy model; tug listeners are additive stages.
  • Group TargetsCamTargetMode::GroupTarget resolves through Cam Manager’s registry.

For conceptual overviews and usage guides:


Get GS_PhantomCam

GS_PhantomCam — Explore this gem on the product page and add it to your project.