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

Return to the regular view of this page.

Tug Fields

Spatial cam-pose reposition system — CameraTugVolumeComponent, CameraTugSourceComponent, TugFieldProxyComponent, TugAimListener, TugBodyListener. Decoupled volume / source / proxy, PhysX layer-gated, no central registry.

Tug Fields are a spatial reposition system that modulates the camera’s pose (position and / or rotation) when a designated proxy entity enters a defined volume. Used for environmental cinematography — pulling the cam toward a vista point when the player walks past a railing, slerping the aim toward a focal point as the player passes a mantle, etc.

The system is three-part decoupled:

  1. CameraTugVolumeComponent — spatial gate. Lives on an entity with a PhysX trigger collider on the TugField layer.
  2. CameraTugSourceComponent — geometric pull configuration. Decoupled from the volume so the activation region and the focal point can be physically apart.
  3. TugFieldProxyComponent — opt-in marker on entities that should engage tug fields. Lives on the target (or its descendant, or a cam-side descendant).

The cam-side consumers are reposition-phase additives:

  • TugAimListener — slerps state.rotation toward the smoothed source point.
  • TugBodyListener — lerps state.position toward the smoothed source point.

No central registry. Filtering happens at PhysX layer config (TugProxy ↔ TugField collision-group pair). Tug volumes broadcast on a per-proxy TugProxyNotificationBus address; listeners maintain their own active-source cache. Per-tick cost is O(volumes the proxy is in) — independent of total world volume count.

Tug-field m_channels ≠ instancing ChannelId. Tug-field channels are arbitrary string tags (“Cinematic”, “Combat”) that match volumes to listeners. They are unrelated to the integer ChannelIds on the Channels & Instancing page despite the shared word. The codebase reuses the identifier name; the docs make the distinction prominent here.

 

Contents


CameraTugVolumeComponent

Spatial gate. Wraps GS_Core::PhysicsTriggerComponent. Lives on an entity with a PhysX trigger collider configured on the dedicated TugField layer.

CameraTugVolumeComponent in the O3DE Inspector

Source resolution rules

The volume’s authored m_sourceEntity:

  • Unset → falls back to self (the volume’s own entity).
  • Valid + source resolved → uses that entity’s CameraTugSourceComponent.
  • Set but unresolvable → field is INERT. Warns once at Activate; TriggerEnter early-returns without notifying.

Authored fields

FieldDefaultPurpose
m_channels{}List of channel tag strings (“env”, “cinematic”, etc.). Hashed to Crc32 once at Activate. Listeners filter by intersection.
m_sourceEntity(entity slot)Optional. Unset → use self.

Per-trigger broadcast

TriggerEnter(proxyEntity):
    if not m_resolvedSource:  return false (inert)
    TugProxyNotificationBus::Event(
        proxyEntity,
        &TugProxyNotifications::OnVolumeEnter,
        GetEntityId() /*volume*/,
        m_channelHashes,
        m_resolvedSource);
    return true

TriggerExit(proxyEntity):
    TugProxyNotificationBus::Event(
        proxyEntity,
        &TugProxyNotifications::OnVolumeExit,
        GetEntityId());
    return true

OnVolumeExit broadcasts unconditionally — listeners that didn’t cache the volume on enter erase a non-existent key (silent no-op).

Request bus

CameraTugVolumeRequestBus — per-entity addressed.

MethodUse
IsProxyInContact(proxyEntity)Predicate — does this volume currently contain the proxy?
GetChannelHashes()Returns the cached Crc32 channel hashes.
GetResolvedSource()Returns the resolved source component (or null if inert).
GetVolumeEntityId()Returns the volume’s own entity id.

Used by listeners during proxy rebind to discover volumes already containing a newly-resolved proxy — PhysX doesn’t re-fire enters when the bus reconnects mid-overlap. See Proxy Rebind Walk.


CameraTugSourceComponent

Pure data + helpers that resolve source and destination world-space points. No collider, no triggering.

CameraTugSourceComponent in the O3DE Inspector

Source vs. destination — fully decoupled

ConceptMeaning
Source pointWhere proximity is measured FROM. The radii are centered on this point; the gravity-well falloff evaluates against the proxy’s distance to it.
Destination pointWhere the cam is pulled TOWARD. Modulation drags the cam (Body) or its forward (Aim) toward this point, weighted by the proximity-derived influence.
CaseSetup
Common — source == destinationCam pulls toward where proximity is measured. Leave m_destinationEntity unset.
DecoupledSource measured around a doorway threshold; destination 10 m past the doorway pointing at a vista. Set m_destinationEntity to the destination point’s entity.
FallbackIf m_destinationEntity is unset, destination = source point.

Authored fields

FieldDefaultPurpose
m_tugPointOffset(0, 0, 0)Local-space offset from THIS entity’s transform — places the proximity-reference point distinct from the entity’s pivot.
m_destinationEntity(entity slot)Optional. Valid → destination = that entity’s world TM × m_destinationOffset.
m_destinationOffset(0, 0, 0)Local-space offset from m_destinationEntity. Ignored when unset.
m_innerRadius1.0 mDeadzone. distance ≤ this = full influence.
m_outerRadius8.0 mZero-influence boundary. distance ≥ this = no contribution.
m_innerWeight1.0Influence at deadzone (full strength).
m_outerWeight0.0Influence at outer boundary. Default zero so engagement ramps smoothly.
m_falloffCurveEaseInOutQuadraticGS_Core::CurveType applied across the outer → inner falloff span.

Helper methods

AZ::Vector3 GetSourcePoint()      const;
AZ::Vector3 GetDestinationPoint() const;
float GetInnerRadius()  const;
float GetOuterRadius()  const;
float GetInnerWeight()  const;
float GetOuterWeight()  const;
GS_Core::CurveType GetFalloffCurve() const;

Listeners read these directly via a cached pointer (no per-tick bus call).


TugFieldProxyComponent

Lightweight opt-in marker. Functions as a marker + bus address. No fields. No logic beyond existing.

The PhysX trigger collider that actually overlaps tug volumes must be authored alongside this component on the same entity, set to the dedicated TugProxy collision layer.

Authoring placements

PlacementUse for
On the target entity directly (player root, boss root)Simple cases.
On a child entity of the targetWhen the proxy should be at chest height, vehicle COM, etc. Tug listeners walk target descendants to find proxies.
On the cam entity (or its child)Fallback for cams that should react to environmental fields without target cooperation.

TugProxyNotificationBus

Per-entity addressed (the proxy entity’s id).

class TugProxyNotifications : public AZ::EBusTraits
{
public:
    using BusIdType = AZ::EntityId;
    static const AZ::EBusAddressPolicy AddressPolicy = AZ::EBusAddressPolicy::ById;
    static const AZ::EBusHandlerPolicy HandlerPolicy = AZ::EBusHandlerPolicy::Multiple;

    virtual void OnVolumeEnter(
        AZ::EntityId volumeEntity,
        const AZStd::vector<AZ::Crc32>& volumeChannels,
        const CameraTugSourceComponent* source) {}

    virtual void OnVolumeExit(AZ::EntityId volumeEntity) {}
};

Multi-handler so multiple listeners (Body + Aim on the same cam, plus diagnostic tooling) can subscribe at the same proxy address.


Listener Stages

TugAimListener and TugBodyListener are reposition-phase additives — see Additive Stage Variants. Both derive from a shared TugListenerBase that holds the channel-matching, proxy resolution, active-source cache, smoothed-influence state, and the per-tick algorithm.

Shared authored fields

FieldDefaultPurpose
m_enabledtrueMaster toggle.
m_channels{}Channel tag strings to match against volume channels. Empty = match all.
m_blendHalflife0.25Smoothing on engagement / disengagement.
m_strength1.0Overall multiplier.

A cam can run only the aim listener, only the body listener, both, or neither — each is independent.


Per-Tick Algorithm

1. ResolveProxy(ctx)
     Walk target descendants → cam-self descendants → inert.
     If changed since last tick, rebind bus + RepopulateFromVolumeWorld.

2. Sum contributions from m_activeSources:
     For each (volume, source) in m_activeSources:
         distance = (proxy.worldPos - source.GetSourcePoint()).length
         if distance >= source.outerRadius:    skip
         t = (source.outerRadius - distance)
             / (source.outerRadius - source.innerRadius)
         t = ApplyCurve(clamp(t, 0, 1), source.falloffCurve)
         weight = lerp(source.outerWeight, source.innerWeight, t) * m_strength
         contribute (weight, source.GetDestinationPoint())
     totalInfluence, weightedSourcePoint = combined

3. Dormancy check:
     If totalInfluence == 0 AND m_smoothedInfluence ≈ 0:
         m_dormant = true; return.   // full early-exit when settled

4. Dormant → engaged seeding (if transitioning):
     m_smoothedSourcePoint = derived.GetSeedSourcePoint(state)
     // Aim:  point along cam's current forward — zero-displacement transition.
     // Body: state.position — zero-displacement transition.

5. Damping:
     alpha = HalflifeAlpha(m_blendHalflife, dt)
     m_smoothedInfluence   = lerp(m_smoothedInfluence,   totalInfluence,      alpha)
     m_smoothedSourcePoint = lerp(m_smoothedSourcePoint, weightedSourcePoint, alpha)

6. derived.ApplyModulation(state, ctx, m_smoothedInfluence, m_smoothedSourcePoint)

TugAimListener.ApplyModulation

Derives the natural rotation directly from ctx (cam → primary target forward), not from state.rotation. This prevents compound-feedback where prior tug modifications would loop into the next tick’s blend source.

naturalFwd   = (ctx.targetTM.translation - ctx.camInitialTM.translation).normalized
naturalRot   = LookAt(naturalFwd, +Z)
modulatedRot = LookAt((sourcePoint - ctx.camInitialTM.translation).normalized, +Z)
state.rotation = Slerp(naturalRot, modulatedRot, smoothedInfluence * m_strength)

TugBodyListener.ApplyModulation

Derives natural pose source from ctx.targetTM / the Body’s state.bodyAnchor sidecar, not state.position.

naturalPos = state.bodyAnchor       // or ctx.targetTM.translation + body offset
state.position = Lerp(naturalPos, smoothedSourcePoint, smoothedInfluence * m_strength)

Proxy Rebind Walk

When the listener’s resolved proxy changes (target switched, proxy entity destroyed), the listener:

  1. Unbinds from the old proxy’s TugProxyNotificationBus.
  2. Clears m_activeSources cache.
  3. Binds to the new proxy’s bus address.
  4. RepopulateFromVolumeWorld — walks all CameraTugVolumeRequestBus handlers and synthesizes OnVolumeEnter for any that already contain the new proxy.

PhysX does not re-fire enter events when a bus reconnects mid-overlap, so this catchup walk is essential. Without it, a switched proxy would miss every volume it’s already inside until it leaves and re-enters.


PhysX Layer Setup

The system requires a specific PhysX collision-group preset configured at project setup time:

LayerUsed byPair with
TugProxyTugFieldProxyComponent’s sibling colliderTugField
TugFieldCameraTugVolumeComponent’s sibling collider (trigger)TugProxy

Both layers overlap only with each other in the collision-group preset. This ensures tug volumes fire triggers only for registered proxies — no general-world filtering needed at runtime.

This is the single most common authoring failure. If you set up volumes and listeners but nothing fires, the PhysX layer config is the first thing to verify. Without the TugProxyTugField pair, no triggers ever fire.


Authoring Workflow

  1. Set up PhysX layers (one-time project setup): create TugProxy and TugField layers; pair them via collision-group preset.
  2. On the player entity (or child): Add TugFieldProxyComponent + a PhysX trigger collider on the TugProxy layer.
  3. On each level entity that should be a tug field: Add CameraTugVolumeComponent + a PhysX trigger collider (trigger mode) on the TugField layer. Set m_channels for filtering.
  4. For decoupled source / destination: Add CameraTugSourceComponent to a separate entity at the focal point; point the volume’s m_sourceEntity at it. Set its m_destinationEntity if the pull point differs from the source.
  5. On the cam: Add TugAimListener and / or TugBodyListener to the cam’s m_additives. Set the listener’s m_channels to match the volumes’ channels (or leave empty to match all).

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.