Stage Pipeline
Categories:
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 Variants —
DefaultFollowBody,OrbitBody,DynamicOrbitBody,LeadingFollowBody,TrackBody. - Aim Stage Variants —
DefaultLookAtAim,ClampedLookAim. Plus extensions from gs_performer. - Additive Stage Variants —
CollisionReposition,OcclusionReposition,TugAimListener,TugBodyListener,PerlinNoise,ImpulseNoise.
Contents
- Per-Tick Model
- Stage Interfaces
- CameraState and CameraContext
- Target Routing
- Decoupled Ideal Pattern
- Damping Convention
- Init Contract
- Polymorphic-Array Convention
- StageHelpers Utility
- Reflection Conventions
- See Also
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);
};
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:
| Stage | What it damps |
|---|---|
| Body | Pursuit of the follow target — halflife on m_idealPosition. |
| Aim | Slerp toward the look-at rotation — halflife on m_idealRotation. |
| Reposition additives | Their own correction delta — collision pushback, tug-source approach. |
| Noise additives | Operate 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:
| Helper | Purpose |
|---|---|
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
FindClassDataguard is mandatory. Multiple stages reflect the shared curve type / common enums chain, and an unguarded reflect call triggers a double-registration crash. EnableForAssetEditorbelongs underSerializeContextonly, notEditContext. 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 insideReflectkeep 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/AdoptPoseSnapshotprotocol. - Orbit Profiles —
.camorbitasset consumed byDynamicOrbitBody. - Noise Profiles —
.camnoiseprofileasset consumed byPerlinNoise/ImpulseNoise. - Tug Fields — volume / source / proxy model; tug listeners are additive stages.
- Group Targets —
CamTargetMode::GroupTargetresolves 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.