This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Cam Core

The runtime camera driver — applies phantom camera state to the real camera with blend interpolation, mid-blend interrupt correction, state inheritance handoff, and orbital blend shapes.

The Cam Core is the runtime driver of the GS_PhantomCam system. It lives on a camera entity (the main O3DE camera entity for single-player, or a per-channel Cam Core entity for multi-channel projects) and is responsible for one job: making the engine’s camera match the dominant Phantom Camera for its channel. Every frame, the Cam Core reads the target phantom camera’s position, rotation, and field of view, then applies those values to the actual camera entity — either instantly (when locked) or through a timed blend transition.

When the Cam Manager determines a new dominant phantom camera for the Cam Core’s channel, it fires a per-channel notification and routes the new cam to the Cam Core via SetPhantomCam. The Cam Core then looks up the best matching blend in the active Blend Profile, optionally inherits state from the outgoing cam, and begins interpolating toward the new phantom camera over the specified duration, easing curve, and blend shape. If a new transition starts before the current blend completes, a small correction window pulls the new blend back from the rendered TM so the cam does not snap. Once a blend completes, the main camera locks to the phantom camera as a child entity, matching its transforms exactly until the next transition.

For usage guides and setup examples, see The Basics: GS_PhantomCam.

Cam Core component in the O3DE Inspector

 

Contents


How It Works

Blend Transitions

When a camera transition is triggered:

  1. The Cam Core receives SetPhantomCam(targetCam) from the Cam Manager, routed to its own entity address.
  2. It queries the assigned Blend Profile for the best matching blend entry between the outgoing and incoming cameras (by entity name).
  3. The matched entry returns duration, easing curve, blend shape (Linear / Spherical / Cylindrical), an inherit-state flag, and a pivot source. If no entry matches, the Cam Core falls back to its own defaults.
  4. If the entry’s inherit-state is set, the state inheritance handoff runs before the blend starts so the destination cam’s body adopts a kinematic interpretation of the outgoing cam’s pose.
  5. The outgoing cam’s m_blendingOut flag is set so it continues ticking through the transition (keeps the source pose live if the target is moving).
  6. Over the blend duration, the Cam Core interpolates position (using the chosen shape), rotation (slerp), and lens (FOV / near / far) from the outgoing state to the incoming state.
  7. If a new blend interrupts this one, the interrupt correction window pulls the new start curve back so the cam doesn’t snap.
  8. On completion, the Cam Core parents the main camera to the new dominant phantom, locking them together until the next transition.

Blend Profile Resolution

The Cam Core holds a reference to a GS_PhantomCamBlendProfile asset. When a transition occurs, it calls GetBestBlend(fromCam, toCam) on the profile to find the most specific matching entry. Resolution order:

  1. Exact match — From camera name to To camera name.
  2. Any-to-specific — “Any” to the To camera name.
  3. Specific-to-any — From camera name to “Any”.
  4. Default fallback — The Cam Core’s own default blend time, easing, and shape.

See Blend Profiles for full details.

Camera Locking

Once a blend completes, the main camera becomes a child of the dominant phantom camera entity. All position, rotation, and property updates from the phantom camera are reflected immediately on the real camera. This lock persists until the next transition begins.


Blend Shape and Pivot

Each blend entry specifies a shape that controls how position interpolates:

ShapeBehavior
LinearStraight-line lerp from source to destination. The default and the cheapest. No pivot used.
CylindricalSweeps through a yaw arc around a pivot, preserving the heading change as a rotation rather than a translation. Pitch and radius interpolate independently.
SphericalSweeps through a great-circle arc around a pivot. Both yaw and pitch animate around the pivot, producing an orbital flight path between source and destination.

The pivot point is resolved by the entry’s pivot source:

Pivot sourceResolution
SharedMidpoint of both cams’ published pivots. Falls back gracefully if either is missing.
SourceOutgoing cam’s pivot. Falls back to the incoming cam, then to Linear if neither publishes.
DestinationIncoming cam’s pivot. Falls back to the outgoing cam, then to Linear.

Cams publish their pivot as the pivotPos field on CamPoseSnapshot (see State Inheritance). Stages that don’t publish a pivot disqualify themselves from being the pivot source.

The math is implemented by the Orbital Solver Utility in GS_Core — BlendPositionAroundPivot(from, to, pivot, alpha, shape, axis).


State Inheritance Handoff

When a blend entry has m_inheritState = true, the Cam Core runs a handoff between the outgoing and incoming cams before starting the blend:

  1. Pull a CamPoseSnapshot from the outgoing cam via PhantomCameraRequests::TryGetPoseSnapshot.
  2. If the snapshot is valid, push it to the incoming cam via PhantomCameraRequests::TryAdoptPoseSnapshot.

The Get / Adopt calls forward to the cam’s Body stage. Bodies that don’t implement the protocol return false; the handoff is a silent no-op and the blend proceeds normally.

The handoff runs before StartBlend, so by the time the blend captures source and destination poses, the destination cam’s body has already adopted the seed pose. This makes inherited blends feel like a continuation of the outgoing cam’s motion rather than a clean start.

See State Inheritance for the full protocol and per-stage adoption semantics.


Interrupt Correction

When a blend A → B is in flight and a new blend A’ → C kicks off, snapping the cam’s source TM produces a perceptible velocity jolt — the cam visibly changes direction at the interrupt moment. The Cam Core’s mid-blend correction window prevents this.

Mechanism

  1. Capture the cam’s currently-rendered TM at the interrupt moment → m_interruptSnapshotTM.
  2. Compute T_natural = 1 - oldBlendFactor — the curve point on the new blend at which the cam’s velocity matches its current motion.
  3. Compute T_start = max(0, T_natural − m_interruptCorrection) — pull the new blend back by the correction window.
  4. Seed the new blend at curve point T_start.
  5. Over the window [T_start, T_natural], the rendered pose is a Y-blend between the snapshot and the new blend’s natural pose, with the Y factor sweeping 0 → 1.
  6. After T_natural, the Y-blend yields fully to the new blend; pure new-blend interpolation continues with live source / destination TMs.

The cam starts in the snapshot pose at the interrupt, ramps gracefully into the new trajectory across the correction window, and proceeds normally.

Window width

m_interruptCorrection is authored on the Cam Core, default 0.03 (3% of the new blend’s curve). Larger windows feel smoother but produce more visible lag in the handoff territory; smaller windows feel snappier but reintroduce the velocity jolt. Setting it to 0 degenerates to legacy hard-anchor behavior.


Channel Addressing

The Cam Core self-registers with the Cam Manager on activate. It walks transform ancestors looking for a ChannelStampComponent to determine its channel:

  • Stamp found — Registers via RegisterCamCoreToChannel(camCore, stampEntity, channelId, token). Listens at its own entity address on CamCoreRequestBus so the Cam Manager can route per-channel SetPhantomCam calls.
  • No stamp — Registers via the legacy RegisterCamCore. Routes through channel 0 in single-player projects.

If the Cam Core’s parent rig is re-stamped (rare; typically during stage transitions or hot-reload), the ChannelStampNotificationBus::OnStamped handler un-registers from the old channel and re-registers to the new one. The Cam Manager’s token validation rejects mismatched stamp tokens to prevent stale messages from overwriting fresh bindings.

One Cam Core per channel. Multi-channel projects (Tier 3) spawn one per active channel; single-player projects use a single Cam Core at channel 0.


Setup

  1. Add GS_CamCoreComponent to a camera entity. For Tier 1 / Tier 2 (single-player), this is the main O3DE camera entity, parented under the Cam Manager entity. For Tier 3 (multi-channel), include one Cam Core per rig inside the rig prefab.
  2. Create a Blend Profile data asset in the Asset Editor and assign it to the Cam Core’s Blend Profile slot.
  3. Configure default blend time, easing, and blend shape on the Cam Core for cases where no profile entry matches. See Curves Utility for available easing types.
  4. Optionally adjust Interrupt Correction (default 0.03) if your project has unusually short or long blends.

API Reference

Request Bus: CamCoreRequestBus

Internal command bus, Cam Manager → Cam Core. Per-entity — each Cam Core listens at its own entity id so the Cam Manager can route per-channel events; Broadcast hits every connected Cam Core (stage-transition global clear). This is not a cross-gem contract and is not script-reflected — external systems observe the camera through the two GS_Core contracts below.

MethodParametersReturnsDescription
SetPhantomCamAZ::EntityId targetCamvoidSets the phantom camera that the Cam Core should blend toward or lock to. Called by the Cam Manager when arbitration produces a new winner. Triggers the inheritance handoff and blend start. EntityId() clears it.

Cross-Gem Contract: CamCoreExchangeBus

The query half of the camera’s observable-service trio, housed in GS_Core. Addressed by the Cam Core’s EntityId; single provider.

MethodParametersReturnsDescription
GetCamCoreAZ::EntityIdResolves the Cam Core’s entity (late-joiner / poll for the current core). Was CamCoreRequestBus::GetCamCore before the interfaces migration.

Cross-Gem Contract: CamCoreEmissionBus

The emit half of the trio, housed in GS_Core. Global broadcast, multiple handlers — any number of components can subscribe without depending on GS_PhantomCam. Was CamCoreNotificationBus before the interfaces migration; the ScriptCanvas-facing name is preserved by GS_PhantomCam’s binder, so existing graphs keep resolving.

EventParametersDescription
UpdateCameraPositioncamPos, camFacing, deltaTimeFired each tick with the committed camera position, forward vector, and frame delta. Subscribe to react to camera movement in real time (audio, UI, gameplay readback).
OnCamCoreRegisteredAZ::EntityId camCoreA Cam Core came online (rig lifetime). Pair with GetCamCore to resolve it, then observe its updates.
OnCamCoreUnregisteredAZ::EntityId camCoreA Cam Core went away.

Inspector Fields

FieldDefaultPurpose
defaultBlendTypeCurveType::LinearEasing curve used when no Blend Profile entry matches.
defaultBlendTime1.0Fallback blend duration in seconds.
defaultBlendShapeBlendShape::LinearFallback blend shape when no profile entry matches.
activeBlendProfileAssetThe active .camblendprofile asset.
m_interruptCorrection0.03Width of the mid-blend interrupt correction window as a fraction of the new blend’s curve. See Interrupt Correction.

Usage Examples

C++ — Querying the Main Camera Entity

#include <GS_Core/Interfaces/Camera/CamCoreExchangeBus.h>

AZ::EntityId mainCameraId;
GS_Core::CamCoreExchangeBus::BroadcastResult(
    mainCameraId,
    &GS_Core::CamCoreExchange::GetCamCore);

For a multi-channel project, query the Cam Manager for a specific channel’s Cam Core instead:

#include <GS_PhantomCam/GS_CamManagerBus.h>

AZ::EntityId p1CamCore;
GS_PhantomCam::CamManagerRequestBus::BroadcastResult(
    p1CamCore,
    &GS_PhantomCam::CamManagerRequests::GetChannelCamCore,
    GS_PhantomCam::ChannelId(0));

C++ — Listening for Camera Updates

#include <GS_Core/Interfaces/Camera/CamCoreEmissionBus.h>

class MyCameraListener
    : public AZ::Component
    , protected GS_Core::CamCoreEmissionBus::Handler
{
protected:
    void Activate() override
    {
        GS_Core::CamCoreEmissionBus::Handler::BusConnect();
    }

    void Deactivate() override
    {
        GS_Core::CamCoreEmissionBus::Handler::BusDisconnect();
    }

    void UpdateCameraPosition(
        const AZ::Vector3& camPos,
        const AZ::Vector3& camFacing,
        float deltaTime) override
    {
        // React to per-tick camera position / facing changes.
    }
};

Script Canvas

Reacting to camera position updates:


Extending the Cam Core

The Cam Core can be extended in C++ to customize blend behavior, add post-processing logic, or integrate with external camera systems.

Header (.h)

#pragma once
#include <GS_PhantomCam/Core/GS_CamCoreBus.h>
#include <Source/Core/GS_CamCoreComponent.h>

namespace MyProject
{
    class MyCamCore : public GS_PhantomCam::GS_CamCoreComponent
    {
    public:
        AZ_COMPONENT_DECL(MyCamCore);

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

Implementation (.cpp)

#include "MyCamCore.h"
#include <AzCore/Serialization/SerializeContext.h>

namespace MyProject
{
    AZ_COMPONENT_IMPL(MyCamCore, "MyCamCore", "{YOUR-UUID-HERE}");

    void MyCamCore::Reflect(AZ::ReflectContext* context)
    {
        if (auto serializeContext = azrtti_cast<AZ::SerializeContext*>(context))
        {
            serializeContext->Class<MyCamCore, GS_PhantomCam::GS_CamCoreComponent>()
                ->Version(0);

            if (AZ::EditContext* editContext = serializeContext->GetEditContext())
            {
                editContext->Class<MyCamCore>("My Cam Core", "Custom camera core driver")
                    ->ClassElement(AZ::Edit::ClassElements::EditorData, "")
                        ->Attribute(AZ::Edit::Attributes::Category, "MyProject")
                        ->Attribute(AZ::Edit::Attributes::AppearsInAddComponentMenu, AZ_CRC_CE("Game"));
            }
        }
    }
}

See Also

For related PhantomCam components:

For utilities used by the blend math:

For conceptual overviews and usage guides:


Get GS_PhantomCam

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

1 - Blend Profiles

Data assets defining camera transition behavior — blend duration, easing curves, blend shape, pivot source, and state inheritance per camera pair.

Blend Profiles are data assets that control how the Cam Core transitions between Phantom Cameras. Each profile contains a list of blend entries. Each entry defines a From camera, a To camera, a blend duration, an easing curve, a blend shape, a pivot source, and an inherit-state flag. This allows every camera-to-camera transition in your project to have unique timing, feel, and geometry.

The GS_PhantomCamBlendProfile asset is authored in the Asset Editor as a .camblendprofile file. It is registered at startup by PhantomCamDataAssetsSystemComponent and assigned to the Cam Core component. When a camera transition occurs, the Cam Core queries the profile for the best matching blend entry.

For usage guides and setup examples, see The Basics: GS_PhantomCam.

Cam Blend Profile asset in the O3DE Asset Editor

 

Contents


How It Works

Blend Entries

Each entry in a Blend Profile defines a single transition rule:

  • From Camera — The name of the outgoing camera (the entity name of the phantom camera being left).
  • To Camera — The name of the incoming camera (the entity name of the phantom camera being transitioned to).
  • Blend Time — The duration of the transition in seconds.
  • Blend Type — The interpolation curve applied during the blend. See Curves Utility for the full list.
  • Blend Shape — Linear, Cylindrical, or Spherical. See Blend Shape.
  • Pivot Source — Source, Destination, or Shared. See Pivot Source. Only consulted when blend shape is not Linear.
  • Inherit State — Opt-in cam-to-cam pose handoff. See Inherit State.

Camera names correspond to the entity names of your phantom camera entities in the scene.

Best Target Blend

When the Cam Core needs to transition, it calls GetBestBlend(fromCam, toCam) on the assigned Blend Profile. The system evaluates blend entries in order of specificity:

  1. Exact match — An entry with both the From and To camera names matching exactly.
  2. Any-to-specific — An entry with From set to blank or “any” and To matching the incoming camera name.
  3. Specific-to-any — An entry with From matching the outgoing camera name and To set to blank or “any”.
  4. Default fallback — If no entry matches, the Cam Core uses its own default blend time, easing, and shape configured on the component.

This layered resolution allows you to define broad defaults (“any” to “any” at 1.0 seconds) while overriding specific transitions (“MenuCam” to “GameplayCam” at 2.5 seconds with ease-in-out and a spherical sweep).

The first match within a specificity tier wins (no further tie-breaking).


Blend Shape

The blend shape controls how the camera’s position interpolates between source and destination. Defaults to Linear so unconfigured / pre-v3 assets retain straight-lerp behavior.

ShapeBehavior
LinearStraight world-space lerp. No orbital math. The default.
CylindricalArc around the resolved pivot on the yaw axis. Pitch and radius interpolate independently. Right for ground cams sharing a target.
SphericalFull 3D arc around the resolved pivot. Right when source and destination cams sit at different heights around a shared target.

When the shape is not Linear, the Cam Core calls the Orbital Solver UtilityBlendPositionAroundPivot(from, to, pivot, alpha, shape, axis) — to compute each tick’s position.

Auto-fallback to Linear. When neither cam reports a pivot (e.g. environmental cams without follow / lookAt targets), the orbital solver isn’t called and the blend lerps straight regardless of the authored shape.

Rotation always slerps; the shape only affects position.


Pivot Source

The pivot is the world-space point that Cylindrical / Spherical shapes arc around. It is resolved at blend start from the cams’ pivots. The pivot source selects which cam’s pivot to use:

Pivot SourceBehavior
SourceOutgoing cam’s pivot. “Leave A elegantly” — the source cam’s framing reference defines the arc.
DestinationIncoming cam’s pivot. “Arrive at B elegantly” — the destination’s framing reference defines the arc.
Shared (default)Midpoint of both pivots. Collapses identically when both cams target the same point.

Pivots come from the cams’ bodies via GetCameraPivot. A cam that doesn’t publish a pivot disqualifies itself from being the pivot source; Cam Core falls back to whichever cam does publish one, or to Linear if neither does.

This field is ignored when Blend Shape is Linear.


Inherit State

Setting Inherit State opts the matched pair into the state inheritance protocol. When the Cam Core is about to start this blend, it:

  1. Calls TryGetPoseSnapshot on the outgoing cam — the body publishes a CamPoseSnapshot.
  2. Calls TryAdoptPoseSnapshot on the incoming cam — the body consumes the snapshot, re-deriving its internal kinematic state to start from a continuation of the outgoing pose.
  3. Bodies that don’t speak the protocol silently no-op and the blend proceeds without inheritance.

The blend then runs from the source cam’s pose to the destination cam’s already-inherited pose — reads as a small smooth drift rather than a swing.

Default is false so cinematic shots authored at specific poses land at those poses unless the author opts in.

Authoring gotcha. If the inherit flag is unchecked on a matched pair, the destination cam falls through to its snap-on-activation seed. For a LeadingFollowBody this looks like “cam orients behind player’s travel direction regardless of source facing.” For DynamicOrbitBody or OrbitBody the cam swings to authored yaw / pitch and blends from there. Both symptoms point at the same root cause — verify the flag in the asset.


Data Model

GS_PhantomCamBlendProfile

The top-level asset class. Extends AZ::Data::AssetData. Registered through PhantomCamDataAssetsSystemComponent. Authored as .camblendprofile.

FieldTypeDescription
BlendListAZStd::vector<PhantomBlend>The list of blend entries defining camera transitions.

PhantomBlend

A single blend entry within the profile. Reflected at Version 4 with a version converter that defaults Blend Shape / Pivot Source to Linear / Shared (pre-v3 assets) and Inherit State to false (pre-v4 assets).

FieldTypeDefaultDescription
FromCameraAZStd::string""Entity name of the outgoing phantom camera. Blank or “any” matches all outgoing cameras.
ToCameraAZStd::string""Entity name of the incoming phantom camera. Blank or “any” matches all incoming cameras.
BlendTimefloat1.0Duration of the blend transition in seconds.
BlendTypeGS_Core::CurveTypeLinearThe easing curve. See Curves Utility for the full enum.
BlendShapeGS_Core::Math::BlendShapeLinearPath geometry — Linear / Cylindrical / Spherical. See Blend Shape.
PivotSourceGS_Core::Math::PivotSourceSharedWhich cam’s pivot to use — Source / Destination / Shared. See Pivot Source.
InheritStateboolfalseOpt-in cam-to-cam pose handoff. See Inherit State.

API Reference

GS_PhantomCamBlendProfile Methods

MethodParametersReturnsDescription
GetBestBlendAZStd::string fromCam, AZStd::string toCamconst PhantomBlend*Returns the best matching blend entry for the given camera pair, or nullptr if no match is found. Resolution follows the specificity hierarchy.

Creating a Blend Profile

  1. Open the Asset Editor in O3DE.
  2. Select New and choose GS_PhantomCamBlendProfile from the asset type list.
  3. Add blend entries using the + button on the Blend List array.
  4. For each entry:
    • Set the From Camera name (or leave blank for “any”).
    • Set the To Camera name (or leave blank for “any”).
    • Set the Blend Time in seconds.
    • Choose a Blend Type (easing curve) from the dropdown.
    • Choose a Blend Shape (Linear / Cylindrical / Spherical).
    • Choose a Pivot Source (Source / Destination / Shared) — ignored when Blend Shape is Linear.
    • Tick Inherit State if you want the incoming cam’s body to seed from the outgoing cam’s pose.
  5. Save the asset.
  6. Assign the asset to the Cam Core component’s Blend Profile inspector slot.

For a full walkthrough, see the PhantomCam Set Up Guide.


See Also

For related PhantomCam components:

For related utilities:

For conceptual overviews and usage guides:


Get GS_PhantomCam

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

2 - State Inheritance

PhantomCam cam-to-cam pose handoff protocol — CamPoseSnapshot universal trait, m_inheritState blend toggle, per-body-stage Get/Adopt implementations, ANGULAR vs POSITION mode, consumedAdoption gate.

State inheritance is a universal pose-handoff protocol: when the Cam Core transitions cam A → cam B, the matched Blend Profile entry may opt into inheritance via the InheritState flag. The outgoing cam publishes a CamPoseSnapshot; the incoming cam consumes it through its own kinematic model. The blend then reads as a small smooth drift instead of a swing.

Inheritance is body-only by design. Aim and additive stages don’t participate — the destination cam’s aim runs fresh on the inherited body pose.

 

Contents


The CamPoseSnapshot Trait

Universal pose-handoff struct. Runtime only — not serialized.

struct CamPoseSnapshot
{
    // Universal — every cam can produce this from its committed transform.
    AZ::Vector3  worldPos = Vector3::Zero();
    AZ::Vector3  worldFwd = Vector3::AxisY();

    // Source cam's resolved pivot in world space at the time of Get.
    // Orbit-style adopters use this to back-derive angular state in the
    // SOURCE cam's frame — preserves orientation even when the incoming
    // cam resolves a different pivot.
    AZ::Vector3  pivotPos      = Vector3::Zero();
    bool         pivotPosValid = false;

    AZ::EntityId pivotEntity;

    // Orbit-specific authored angular state. Populated by orbit-style Get
    // implementations; consumed by orbit-style Adopters directly, bypassing
    // back-derivation. Preserves A's exact yaw / pitch on B across different
    // orbit shapes — angular continuity wins over spatial back-fit.
    float        yawRad             = 0.0f;
    float        pitchRad           = 0.0f;
    bool         angularStateValid  = false;

    // Producer signal — set by TryGetPoseSnapshot when the snapshot is usable.
    bool         valid = false;

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

A snapshot may carry partial information. worldPos + worldFwd are always populated when valid is true. pivotPos is populated by bodies that publish a pivot (orbit-style, lead-follow). angularStateValid is set only by orbit-style bodies that publish authored yaw / pitch directly.


Opting In — The Blend Profile Toggle

Authored per-pair on PhantomBlend entries inside a .camblendprofile:

struct PhantomBlend {
    // ... other fields ...
    bool m_inheritState = false;
};

Default false so cinematic shots authored at specific poses land at those poses unless the author opts in. See Blend Profiles — Inherit State for the authoring surface.


The IBodyStage Virtuals

class IBodyStage {
public:
    // ... other interface ...

    // Default returns false — stage doesn't speak the protocol.
    virtual bool TryGetPoseSnapshot(CamPoseSnapshot& out)        const { return false; }
    virtual bool TryAdoptPoseSnapshot(const CamPoseSnapshot& in)       { return false; }
};

A body stage may implement only one side of the protocol. Static-shot bodies (OrbitBody) implement Get but leave Adopt at default false — their authored angles define their identity. Other body stages typically implement both.

Forwarders on the Phantom Camera

The component forwards both calls to the body slot:

bool GS_PhantomCameraComponent::TryGetPoseSnapshot(CamPoseSnapshot& out) const {
    if (IBodyStage* body = GetBody()) {
        return body->TryGetPoseSnapshot(out);
    }
    return false;
}

bool GS_PhantomCameraComponent::TryAdoptPoseSnapshot(const CamPoseSnapshot& in) {
    if (IBodyStage* body = GetBody()) {
        return body->TryAdoptPoseSnapshot(in);
    }
    return false;
}

Exposed on PhantomCameraRequestBus so the Cam Core can address cams uniformly.


Cam Core Handoff Site

Inside SetPhantomCam, after lastCam / currentCam are determined and before StartBlend:

const bool wantInherit = (bestBlend && bestBlend->m_inheritState
                          && lastCam.IsValid() && currentCam.IsValid());

if (wantInherit)
{
    CamPoseSnapshot snap;
    bool gotPose = false;
    PhantomCameraRequestBus::EventResult(gotPose, lastCam,
        &PhantomCameraRequests::TryGetPoseSnapshot, snap);

    if (gotPose && snap.valid)
    {
        bool adopted = false;
        PhantomCameraRequestBus::EventResult(adopted, currentCam,
            &PhantomCameraRequests::TryAdoptPoseSnapshot, snap);
        (void)adopted;
    }
}

// ... then StartBlend.

The handoff runs before StartBlend, so by the time the blend captures source and destination poses, the destination cam’s body has already adopted the seed pose. The blend then interpolates from the source’s literal position to the destination’s already-inherited pose.

Bodies that don’t speak the protocol (or that reject the inbound pose) return false; the handoff is silent and the blend proceeds normally.


Per-Variant Implementations

DefaultFollowBody

SideBehavior
GetNot implemented (default false).
AdoptNot implemented (default false).

Inheritance into a Default Follow falls through to the Blend Profile’s visual bridge.

OrbitBody (Get-only)

SideBehavior
GetPublishes the cleanest snapshot in the framework. worldPos = m_idealPosition, worldFwd toward cached pivot, pivotPos = m_lastPivot (post-offset target, pre-orbit), AND direct authored yawRad / pitchRad + angularStateValid = true. Orbit-style adopters receive ANGULAR-mode handoff with no back-derivation.
AdoptDisabled (default false). The authored yaw / pitch define the static shot’s identity; accepting inbound pose would defeat author intent.

DynamicOrbitBody (full Get + Adopt)

Get:

out.worldPos          = m_idealPosition;
out.worldFwd          = (m_lastResolvedPivot - m_idealPosition).GetNormalizedSafe();
out.pivotPos          = m_lastResolvedPivot;
out.pivotPosValid     = true;
out.pivotEntity       = m_lastResolvedPivotEntity;
out.yawRad            = m_targetYaw;
out.pitchRad          = m_targetPitch;
out.angularStateValid = true;
out.valid             = true;
return true;

Requires a committed pose AND a cached pivot. First-tick adopters that haven’t ticked yet return false.

Adopt stashes the snapshot. The actual derivation happens in the next Evaluate where pivot resolution has run:

if (m_hasPendingAdoption && m_orbitShape.Get())
{
    if (m_pendingAdoption.angularStateValid)
    {
        // ANGULAR mode (preferred). Inherit raw yaw / pitch directly.
        m_targetYaw   = WrapYawToPi(
            ShortestYawTarget(m_targetYaw, m_pendingAdoption.yawRad));
        m_targetPitch = AZ::GetClamp(m_pendingAdoption.pitchRad,
                                     CameraOrbitShape::kPitchAtLow,
                                     CameraOrbitShape::kPitchAtHigh);
    }
    else
    {
        // POSITION mode (fallback). Back-derive yaw from worldPos.
        const AZ::Vector3 referencePivot = m_pendingAdoption.pivotPosValid
            ? m_pendingAdoption.pivotPos
            : pivot;
        const AZ::Vector3 off = m_pendingAdoption.worldPos - referencePivot;

        const float yawObserved    = std::atan2(off.GetY(), off.GetX());
        const float radiusObserved = std::sqrt(off.GetX()*off.GetX() + off.GetY()*off.GetY());
        const float heightObserved = off.GetZ();

        m_targetYaw   = WrapYawToPi(ShortestYawTarget(m_targetYaw, yawObserved));
        m_targetPitch = FindPitchOnShape(*m_orbitShape.Get(), radiusObserved, heightObserved);
    }

    m_idealPosition       = m_pendingAdoption.worldPos;   // seed at adopted pose
    m_hasIdeal            = true;
    consumedAdoption      = true;
    m_hasPendingAdoption  = false;
}

The ANGULAR path is preferred — it preserves authored angular state across different orbit shape assets, which the position back-derivation cannot. The POSITION path is the safety net for source bodies that don’t publish angular state.

LeadingFollowBody (facing-seeded standoff)

Get returns m_idealPosition, world-forward toward cached target, pivotPos = m_lastTargetPoint. No angular state — lead-follow has no degree of freedom other than position-in-band.

Adopt stashes the snapshot. The consume block in Evaluate reads source worldFwd, flattens to the XY plane, and seeds the cam at band-natural standoff distance:

AZ::Vector3 inboundFwd = m_pendingAdoption.worldFwd;
inboundFwd.SetZ(0.0f);
inboundFwd = inboundFwd.GetNormalizedSafe();
if (inboundFwd.GetLengthSq() < epsilon)
    inboundFwd = GetHorizontalForward(stageTargetTM);   // fallback

const float seedDist = AZ::Lerp(m_innerRadius, m_outerRadius, 0.6f);
m_idealPosition = targetPoint - inboundFwd * seedDist;

m_hasIdeal           = true;
m_idleTimer          = 0.0f;
m_hasLastTarget      = true;
consumedAdoption     = true;
m_hasPendingAdoption = false;

Why facing-seeded, not position-seeded? Lead-follow is heading-agnostic. Seeding with the source’s literal worldPos would lock the cam at an off-band angle, defeating the body’s authored intent. The cam inherits the source’s facing at this body’s preferred standoff. Z naturally lands at targetPoint.Z (inboundFwd is XY-flattened) — a from-below source produces a band-natural pose, not stuck-below framing.

TrackBody (path-projection with threshold)

Get returns m_idealPosition plus world-forward toward cached target. No angular state, no pivot — the path is the kinematic reference, not a point.

Adopt stashes. The consume block projects the source’s position onto the spline and rejects when the source sits too far from the path:

EnsureSplineResolved();
if (!m_spline) { m_hasPendingAdoption = false; return; }

AZ::Vector3 nearestWorld;
const float dist = m_spline->FindClosestWorldPoint(
    m_pendingAdoption.worldPos, nearestWorld, /*outT*/);

if (dist > m_adoptionPathThreshold)
{
    // Source sits too far from path — reject, fall back to plain blend.
    m_hasPendingAdoption = false;
    return;
}

m_idealPosition  = m_pendingAdoption.worldPos;
m_springVelocity = AZ::Vector3::CreateZero();
m_hasIdeal       = true;
consumedAdoption = true;
m_hasPendingAdoption = false;

m_adoptionPathThreshold is authored on the TrackBody (default 5.0 m). Prevents teleport-snap when the source cam sits well off the dolly’s path.


The consumedAdoption Gate

Problem: With ctx.snapThisFrame = true on activation, every body’s damping clause snap-clobbers m_idealPosition = desiredPos, discarding the adoption seed. Symptom: cam jerks to its natural ideal then blends from there.

Fix: The snap-on-activation block in every body is gated on !consumedAdoption:

if ((ctx.snapThisFrame || !m_hasIdeal) && !consumedAdoption)
{
    // Snap-seed path. Skipped when adoption already placed m_idealPosition.
}

Adoption-tick uses standard damping from the adopted seed, not snap-seed. When inheritance fires, the seed pose is preserved through the activation tick.


Authoring Gotcha — Matched but Not Inheriting

If m_inheritState is unchecked on a matched blend pair, the destination cam falls through to its snap-on-activation seed instead of consuming the source’s pose. Symptoms vary by body type but all point to the same root cause:

BodySymptom
LeadingFollowBody“Cam orients behind player’s travel direction regardless of source facing.” The snap block uses GetHorizontalForward(stageTargetTM) = target’s BasisY.
DynamicOrbitBody / OrbitBodyCam swings to authored yaw / pitch then blends from there.
TrackBodyDolly starts at its untouched starting spline parameter.

The fix is data-side: check the Inherit State flag on the matched blend entry inside the .camblendprofile asset.

If diagnostic prints are added during debugging, the smoking gun looks like:

Handoff: from=OrbitCam to=LeadCam inherit=0 (matched=1)

matched = 1, inherit = 0 means the blend pair IS in the asset, but m_inheritState is unchecked on it.


Adoption Coverage Matrix

Body VariantGetAdoptSource publishes
DefaultFollowBody
OrbitBody (static)— (Get-only)worldPos, worldFwd, pivotPos, full angular
DynamicOrbitBody✓ (ANGULAR / POSITION)worldPos, worldFwd, pivotPos, full angular
LeadingFollowBody✓ (facing-seeded standoff)worldPos, worldFwd, pivotPos (= target), no angular
TrackBody✓ (path-project + threshold)worldPos, worldFwd, no angular, no pivot

See Also

Related PhantomCam pages:

Basics-side authoring guide:


Get GS_PhantomCam

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

3 - Interrupt Blending

Mid-blend interrupt correction window — m_interruptCorrection, T_start / T_natural derivation, snapshot Y-blend. Prevents velocity discontinuities when a new blend starts before the current one finishes.

When a blend A → B is in flight and a new blend A’ → C kicks off, snapping the cam’s source pose produces a perceptible velocity jolt — the cam visibly changes direction at the interrupt moment. The Cam Core’s mid-blend correction window prevents this by seeding the new blend at a velocity-matched curve point and Y-blending between the snapshot pose and the new blend’s natural pose across a small window.

This replaces the legacy BlendFromCore hard-anchor approach, which produced a velocity-zero discontinuity at every interrupt.

 

Contents


The Problem

Consider a blend A → B running at 50% progress. A new winner C is arbitrated; the system needs a new blend A’ → C. Two naive approaches both fail:

ApproachBehaviorFailure
Snap to mid-blend pose, start new blend from thereSource pose = currently-rendered TMThe new blend’s start velocity is whatever its curve dictates at t=0 (often zero) — does not match the cam’s current motion vector. Visible direction change at interrupt.
Hard-anchor the snapshot as the new blend’s source for the entire durationSource TM frozen at interruptThe cam’s velocity drops to zero at t=0 of the new blend — instant velocity discontinuity.

The legacy BlendFromCore flag implemented the second approach. The new correction window replaces it.


The Mechanism

The Cam Core introduces a small correction window at the start of the new blend during which the rendered TM is a Y-blend between the snapshot pose and the new blend’s natural pose. After the window, pure new blend with live source / destination TMs.

Step by step

  1. Capture the cam’s currently-rendered TM at the interrupt moment → m_interruptSnapshotTM.
  2. Compute oldBlendFactor = oldCurTime / oldTargetTime (how far through the OLD blend we were).
  3. Compute T_natural = 1 − oldBlendFactor — the curve point on the new blend at which the cam’s velocity matches its current motion.
  4. Compute T_start = max(0, T_natural − m_interruptCorrection) — pull the new blend back by the configurable window width.
  5. Seed curBlendTime = T_start * blendTime so the new blend starts at curve-point T_start.
  6. Over the window [T_start, T_natural], the rendered pose is a Y-blend between m_interruptSnapshotTM and the new blend’s natural pose at the current curve point. The Y-factor sweeps 0 → 1 across the window.
  7. After T_natural, the Y-blend yields fully to the new blend; pure new-blend interpolation continues with live source / destination TMs.

Net effect: the rendered cam starts in the snapshot pose at the interrupt, ramps gracefully into the new blend’s natural trajectory across the correction window, and proceeds normally.


Window Width

m_interruptCorrection is authored on the Cam Core, default 0.03 (3% of the new blend’s curve duration). The tradeoff:

SettingBehavior
Larger window (e.g. 0.08)Smoother handoff, but the cam is “stuck” in snapshot territory for longer — can feel like lag.
Smaller window (e.g. 0.01)Quicker handoff, less smoothness.
0Degenerates to legacy hard-anchor behavior with the perceptible jolt.

3% is a balance that’s invisibly smooth for typical blend durations (~1s → 30ms correction window).


Edge Cases

Old blend nearly complete

If oldBlendFactor is close to 1 (the old blend was almost done), T_natural ≈ 0 and T_start ≈ −m_interruptCorrection. The window may compress to nothing — T_start clamps at 0:

T_start = max(0.0, T_natural - m_interruptCorrection);

When clamped, the new blend effectively starts from its natural beginning. The cam may still have a small handoff jolt because the correction can’t pull back further.

Old blend just started

If oldBlendFactor is close to 0 (old blend barely started), T_natural ≈ 1 and T_start ≈ 1 − m_interruptCorrection. Most of the new blend happens in pure new mode after a brief correction window. The snapshot influence is short-lived.

Non-blending interrupt (cold start of new blend)

If IsBlending == false when StartBlend is called, no interrupt — set m_inCorrectionWindow = false and run the new blend normally.


Cam Core State

class GS_CamCoreComponent {
    // ...
    float          m_interruptCorrection = 0.03f;     // authored

    bool           m_inCorrectionWindow   = false;
    float          m_correctionStart      = 0.0f;     // blendFactor where window begins
    float          m_correctionEnd        = 0.0f;     // blendFactor where window ends (= T_natural)
    AZ::Transform  m_interruptSnapshotTM  = AZ::Transform::CreateIdentity();
};

StartBlend Detection

void GS_CamCoreComponent::StartBlend(
    float blendTime,
    GS_Core::CurveType blendType,
    GS_Core::Math::BlendShape blendShape,
    GS_Core::Math::PivotSource pivotSource)
{
    if (IsBlending)
    {
        // Mid-blend interrupt.
        const float oldBlendFactor = curBlendTime / targetBlendTime;
        const float T_natural      = 1.0f - oldBlendFactor;
        const float T_start        = AZ::GetMax(0.0f, T_natural - m_interruptCorrection);

        // Snapshot current rendered TM.
        AZ::TransformBus::EventResult(m_interruptSnapshotTM, m_entityId,
            &AZ::TransformInterface::GetWorldTM);

        m_inCorrectionWindow = true;
        m_correctionStart    = T_start;
        m_correctionEnd      = T_natural;

        // Seed new blend's curve position so velocity matches.
        curBlendTime = T_start * blendTime;
    }
    else
    {
        // Cold start.
        m_inCorrectionWindow = false;
        curBlendTime = 0.0f;
    }

    // Capture source / dest poses, blend params.
    prevTransform      = ... ;          // cam's current world TM at this moment
    targetBlendTime    = blendTime;
    currentBlendType   = blendType;
    currentBlendShape  = blendShape;
    currentPivotSource = pivotSource;
    IsBlending         = true;
}

OnTick Application

void GS_CamCoreComponent::OnTick(float deltaTime, AZ::ScriptTimePoint)
{
    if (!IsBlending)
    {
        // Locked phase — read currentCam's TM, write it directly.
        return;
    }

    curBlendTime += deltaTime;
    const float blendFactor = AZ::GetMin(1.0f, curBlendTime / targetBlendTime);
    const float easedFactor = ApplyCurve(blendFactor, currentBlendType);

    // Compute the "natural" blended pose for this curve point.
    AZ::Vector3 blendedPos;
    if (currentBlendShape == BlendShape::Linear)
    {
        blendedPos = AZ::Vector3::Lerp(
            prevTransform.GetTranslation(),
            currentTM.GetTranslation(),
            easedFactor);
    }
    else
    {
        AZ::Vector3 pivot = ResolvePivot(currentPivotSource, lastCam, currentCam);
        blendedPos = GS_Core::Math::BlendPositionAroundPivot(
            prevTransform.GetTranslation(),
            currentTM.GetTranslation(),
            pivot, easedFactor, currentBlendShape, AZ::Vector3::CreateAxisZ());
    }
    AZ::Quaternion blendedRot = prevTransform.GetRotation().Slerp(
        currentTM.GetRotation(), easedFactor);

    AZ::Vector3    appliedPos;
    AZ::Quaternion appliedRot;

    if (m_inCorrectionWindow && blendFactor < m_correctionEnd)
    {
        // Y-blend the snapshot with the natural pose.
        float yFactor = (blendFactor - m_correctionStart) / m_interruptCorrection;
        yFactor = AZ::GetClamp(yFactor, 0.0f, 1.0f);

        appliedPos = AZ::Vector3::Lerp(
            m_interruptSnapshotTM.GetTranslation(), blendedPos, yFactor);
        appliedRot = m_interruptSnapshotTM.GetRotation().Slerp(blendedRot, yFactor);
    }
    else
    {
        // Past the correction window — pure new blend.
        appliedPos = blendedPos;
        appliedRot = blendedRot;
    }

    // Write final TM. Apply lens interpolation similarly.
    AZ::TransformBus::Event(m_entityId,
        &AZ::TransformInterface::SetWorldTranslation, appliedPos);
    AZ::TransformBus::Event(m_entityId,
        &AZ::TransformInterface::SetWorldRotationQuaternion, appliedRot);

    if (blendFactor >= 1.0f)
    {
        CompleteBlend();
    }
}

Inspector Field

FieldDefaultPurpose
m_interruptCorrection0.03Fraction of the new blend’s curve over which the cam’s snapshot pose at interrupt blends to the new blend’s natural pose. Smaller = snappier interrupts but more visible jolts. 0 = legacy hard-anchor.

What This Replaces

Pre-correction, the system used a BlendFromCore flag on the Cam Core. When set, the blend’s source pose was hard-anchored to the cam’s currently-rendered TM at the interrupt moment, for the entire duration of the new blend. The new blend’s velocity at t = 0 was therefore zero — an instant velocity discontinuity at the interrupt point.

The correction-window approach replaces this entirely. BlendFromCore is retained as a field on the Cam Core for back-compat but is not used in the new flow.


See Also

Related PhantomCam pages:

  • Cam Core — owns StartBlend and OnTick.
  • State Inheritance — runs BEFORE StartBlend; the destination cam’s pose is already inherited when the correction-window snapshot is captured.
  • Blend Profiles — author the entries that trigger blends.
  • Phantom Cameras — Execution Statesm_blendingOut keeps the outgoing cam ticking during the blend so its source pose stays live.

Get GS_PhantomCam

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