Gameplay Ability System コードリーディング ⑦ WaitTargetData - SingleLineTrace

上記の続きになります。

ここまでは簡単そうな Radius (AGameplayAbilityTargetActor_Radius) を見つつ、ConfirmationType などを見てきました。

今回からは AGameplayAbilityTargetActor_Trace から続くトレース関連を見ていきます。

SingleLineTrace

SingleLineTrace Radius

RadiusRadius プロパティだけに対して SingleLineTraceMax Range, Trace Profile, Trace Affects Aim Pitch の 3 つが用意されています。他は共通しています。

共通する部分はこれらの基底である AGameplayAbilityTargetActor に定義されているものです。

ヘッダ

AGameplayAbilityTargetActor_SingleLineTraceAGameplayAbilityTargetActor_Trace を継承しているため、SingleLineTrace は以下のように小さいです。

UCLASS(Blueprintable)
class GAMEPLAYABILITIES_API AGameplayAbilityTargetActor_SingleLineTrace : public AGameplayAbilityTargetActor_Trace
{
    GENERATED_UCLASS_BODY()

protected:
    virtual FHitResult PerformTrace(AActor* InSourceActor) override;
};

親の Trace 側は以下のようになっています。

/** Intermediate base class for all line-trace type targeting actors. */
UCLASS(Abstract, Blueprintable, notplaceable, config=Game)
class GAMEPLAYABILITIES_API AGameplayAbilityTargetActor_Trace : public AGameplayAbilityTargetActor
{
    GENERATED_UCLASS_BODY()

public:
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

    /** Traces as normal, but will manually filter all hit actors */
    static void LineTraceWithFilter(FHitResult& OutHitResult, const UWorld* World, const FGameplayTargetDataFilterHandle FilterHandle, const FVector& Start, const FVector& End, FName ProfileName, const FCollisionQueryParams Params);

    /** Sweeps as normal, but will manually filter all hit actors */
    static void SweepWithFilter(FHitResult& OutHitResult, const UWorld* World, const FGameplayTargetDataFilterHandle FilterHandle, const FVector& Start, const FVector& End, const FQuat& Rotation, const FCollisionShape CollisionShape, FName ProfileName, const FCollisionQueryParams Params);

    void AimWithPlayerController(const AActor* InSourceActor, FCollisionQueryParams Params, const FVector& TraceStart, FVector& OutTraceEnd, bool bIgnorePitch = false) const;

    static bool ClipCameraRayToAbilityRange(FVector CameraLocation, FVector CameraDirection, FVector AbilityCenter, float AbilityRange, FVector& ClippedPosition);

    virtual void StartTargeting(UGameplayAbility* Ability) override;

    virtual void ConfirmTargetingAndContinue() override;

    virtual void Tick(float DeltaSeconds) override;

    UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ExposeOnSpawn = true), Category = Trace)
    float MaxRange;

    UPROPERTY(BlueprintReadWrite, EditAnywhere, config, meta = (ExposeOnSpawn = true), Category = Trace)
    FCollisionProfileName TraceProfile;

    // Does the trace affect the aiming pitch
    UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ExposeOnSpawn = true), Category = Trace)
    bool bTraceAffectsAimPitch;

protected:
    virtual FHitResult PerformTrace(AActor* InSourceActor) PURE_VIRTUAL(AGameplayAbilityTargetActor_Trace, return FHitResult(););

    FGameplayAbilityTargetDataHandle MakeTargetData(const FHitResult& HitResult) const;

    TWeakObjectPtr<AGameplayAbilityWorldReticle> ReticleActor;
};

以下に入力ピンになっているものを抜き出しましたが、先程確認した 3 つが定義されていることがわかります。つまり、これらは SingleLineTrace 固有のものではなく、Trace 全般で共通して利用するプロパティのようです。

UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ExposeOnSpawn = true), Category = Trace)
float MaxRange;

UPROPERTY(BlueprintReadWrite, EditAnywhere, config, meta = (ExposeOnSpawn = true), Category = Trace)
FCollisionProfileName TraceProfile;

// Does the trace affect the aiming pitch
UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (ExposeOnSpawn = true), Category = Trace)
bool bTraceAffectsAimPitch;

StartTargeting

Radius でも確認しましたが、StartTargeting は、これら AGameplayAbilityTargetActor を利用する WaitTargetData (AbilityTask_WaitTargetData) から呼び出されます。

AGameplayAbilityTargetActorWaitTargetData の初期処理で Spawn され、StartTargeting を呼び出してこの Actor の開始が行われるということです。

void AGameplayAbilityTargetActor_Trace::StartTargeting(UGameplayAbility* InAbility)
{
    Super::StartTargeting(InAbility);
    SourceActor = InAbility->GetCurrentActorInfo()->AvatarActor.Get();

    if (ReticleClass)
    {
        // 略 
        // ReticleActor = ~
    }
}

Reticle に関してはここでは割愛しますので、SourceActor として Ability が紐付けられている Actor (例えばプレイヤーキャラクター)を入れています。

EndPlay

AActorEndPlay の override です。

void AGameplayAbilityTargetActor_Trace::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    if (ReticleActor.IsValid())
    {
        ReticleActor.Get()->Destroy();
    }

    Super::EndPlay(EndPlayReason);
}

StartTargetingReticleActor に値が入れられた場合にそれを Destroy しています。

Tick

AGameplayAbilityTargetActor は Actor なので Tick が動作します。

AGameplayAbilityTargetActor_Trace::AGameplayAbilityTargetActor_Trace(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    PrimaryActorTick.bCanEverTick = true;
    PrimaryActorTick.TickGroup = TG_PostUpdateWork;

    MaxRange = 999999.0f;
    bTraceAffectsAimPitch = true;
}

コンストラクタを見てわかる通り Tick の設定もされています。

void AGameplayAbilityTargetActor_Trace::Tick(float DeltaSeconds)
{
    // very temp - do a mostly hardcoded trace from the source actor
    if (SourceActor && SourceActor->GetLocalRole() != ENetRole::ROLE_SimulatedProxy)
    {
        FHitResult HitResult = PerformTrace(SourceActor);
        FVector EndPoint = HitResult.Component.IsValid() ? HitResult.ImpactPoint : HitResult.TraceEnd;

#if ENABLE_DRAW_DEBUG
        if (bDebug)
        {
            DrawDebugLine(GetWorld(), SourceActor->GetActorLocation(), EndPoint, FColor::Green, false);
            DrawDebugSphere(GetWorld(), EndPoint, 16, 10, FColor::Green, false);
        }
#endif // ENABLE_DRAW_DEBUG

        SetActorLocationAndRotation(EndPoint, SourceActor->GetActorRotation());
    }
}

トレースの概要 | Unreal Engine ドキュメント

PerformTraceSingleLineTrace 側での実装になっていますのでここではスキップします。

ただ、FHitResult を返しており、Hit があるかないかで ImpactPointTraceEnd を取得しており、それに応じて自身(つまり AGameplayAbilityTargetActor_SingleLineTrace) の SetActorLocationAndRotation を呼び出しています。

継続して Trace をするためにこの Actor 自身も移動しています。(どうして EndPoint に移動するのか、、は分かりません 😥)

AGameplayAbilityTargetActor_SingleLineTrace::PerformTrace

Tick で呼び出される関数と分かりましたので引き続き PerformTrace の詳細を見ていきます。

FHitResult AGameplayAbilityTargetActor_SingleLineTrace::PerformTrace(AActor* InSourceActor)
{
    bool bTraceComplex = false;
    TArray<AActor*> ActorsToIgnore;

    ActorsToIgnore.Add(InSourceActor);

    FCollisionQueryParams Params(SCENE_QUERY_STAT(AGameplayAbilityTargetActor_SingleLineTrace), bTraceComplex);
    Params.bReturnPhysicalMaterial = true;
    Params.AddIgnoredActors(ActorsToIgnore);

    FVector TraceStart = StartLocation.GetTargetingTransform().GetLocation(); // InSourceActor->GetActorLocation();
    FVector TraceEnd;
    AimWithPlayerController(InSourceActor, Params, TraceStart, TraceEnd);        // Effective on server and launching client only

    // ------------------------------------------------------

    FHitResult ReturnHitResult;
    LineTraceWithFilter(ReturnHitResult, InSourceActor->GetWorld(), Filter, TraceStart, TraceEnd, TraceProfile.Name, Params);

    //Default to end of trace line if we don't hit anything.
    if (!ReturnHitResult.bBlockingHit)
    {
        ReturnHitResult.Location = TraceEnd;
    }
    if (AGameplayAbilityWorldReticle* LocalReticleActor = ReticleActor.Get())
    {
        // 略
    }

#if ENABLE_DRAW_DEBUG
    if (bDebug)
    {
        // 略
    }
#endif // ENABLE_DRAW_DEBUG
    return ReturnHitResult;
}

ここまでで Trace の準備ができたため、AGameplayAbilityTargetActor_Trace::LineTraceWithFilter に上記で準備した値が渡され、ReturnHitResult に値が結果が入ります。

その結果を受けて if (!ReturnHitResult.bBlockingHit) で分かる通り、ブロッキングでヒットした場合以外は TraceEndReturnHitResult.Location に入れられます。 (Overlap の場合はヒットしていないと同じ扱いということですかね)

LineTraceWithFilter

AGameplayAbilityTargetActor_Trace の関数なので、SingleLineTrace 固有ではありません。

void AGameplayAbilityTargetActor_Trace::LineTraceWithFilter(FHitResult& OutHitResult, const UWorld* World, const FGameplayTargetDataFilterHandle FilterHandle, const FVector& Start, const FVector& End, FName ProfileName, const FCollisionQueryParams Params)
{
    check(World);

    TArray<FHitResult> HitResults;
    World->LineTraceMultiByProfile(HitResults, Start, End, ProfileName, Params);

    OutHitResult.TraceStart = Start;
    OutHitResult.TraceEnd = End;

    for (int32 HitIdx = 0; HitIdx < HitResults.Num(); ++HitIdx)
    {
        const FHitResult& Hit = HitResults[HitIdx];

        if (!Hit.HitObjectHandle.IsValid() || FilterHandle.FilterPassesForActor(Hit.HitObjectHandle.FetchActor()))
        {
            OutHitResult = Hit;
            OutHitResult.bBlockingHit = true; // treat it as a blocking hit
            return;
        }
    }
}

入力ピンで受け取っていた ProfileName を使って LineTraceMultiByProfile を実行しています。

ここで取得できた TArray<FHitResult> から単一の OutHitResult を返すわけですが、for ループで回して条件の合致をチェックして最初に合致したものの FHitResult の情報を使っているようです。

if (!Hit.HitObjectHandle.IsValid() || FilterHandle.FilterPassesForActor(Hit.HitObjectHandle.FetchActor())) に関してですが、後者の Filter に関しては、入力ピンの Filter を使っている部分は Radius でも同様の判定があったので理解できるのですが、!Hit.HitObjectHandle.IsValid() に関しては私の知識ではピンときませんでした。

HitObjectHandle が無い Hit というものがどういうケースをさしているのかが分かりませんが、その場合も有効なブロッキングヒットとして扱うようになっているようです。

AimWithPlayerController

こちらも AGameplayAbilityTargetActor_Trace の関数です。前述の通り、この関数の結果として利用するのは OutTraceEnd です。

void AGameplayAbilityTargetActor_Trace::AimWithPlayerController(const AActor* InSourceActor, FCollisionQueryParams Params, const FVector& TraceStart, FVector& OutTraceEnd, bool bIgnorePitch) const
{
    if (!OwningAbility) // Server and launching client only
    {
        return;
    }

    APlayerController* PC = OwningAbility->GetCurrentActorInfo()->PlayerController.Get();
    check(PC);

    FVector ViewStart;
    FRotator ViewRot;
    PC->GetPlayerViewPoint(ViewStart, ViewRot);

    const FVector ViewDir = ViewRot.Vector();
    FVector ViewEnd = ViewStart + (ViewDir * MaxRange);

    ClipCameraRayToAbilityRange(ViewStart, ViewDir, TraceStart, MaxRange, ViewEnd);

    FHitResult HitResult;
    LineTraceWithFilter(HitResult, InSourceActor->GetWorld(), Filter, ViewStart, ViewEnd, TraceProfile.Name, Params);

    const bool bUseTraceResult = HitResult.bBlockingHit && (FVector::DistSquared(TraceStart, HitResult.Location) <= (MaxRange * MaxRange));

    const FVector AdjustedEnd = (bUseTraceResult) ? HitResult.Location : ViewEnd;

    FVector AdjustedAimDir = (AdjustedEnd - TraceStart).GetSafeNormal();
    if (AdjustedAimDir.IsZero())
    {
        AdjustedAimDir = ViewDir;
    }

    if (!bTraceAffectsAimPitch && bUseTraceResult)
    {
        FVector OriginalAimDir = (ViewEnd - TraceStart).GetSafeNormal();

        if (!OriginalAimDir.IsZero())
        {
            // Convert to angles and use original pitch
            const FRotator OriginalAimRot = OriginalAimDir.Rotation();

            FRotator AdjustedAimRot = AdjustedAimDir.Rotation();
            AdjustedAimRot.Pitch = OriginalAimRot.Pitch;

            AdjustedAimDir = AdjustedAimRot.Vector();
        }
    }

    OutTraceEnd = TraceStart + (AdjustedAimDir * MaxRange);
}

まず PC->GetPlayerViewPoint(ViewStart, ViewRot); です。
APlayerController::GetPlayerViewPoint | Unreal Engine Documentation

GASDocumentation の場合は TPS なので、キャラクターの後ろにあるカメラのポイントになります。

FVector ViewEnd = ViewStart + (ViewDir * MaxRange);ViewEnd を出していますが、MaxRange が入力ピンから受け取っているものなので、ここだけみると ViewEnd はカメラからの距離で測っているようです。

ClipCameraRayToAbilityRange

ClipCameraRayToAbilityRange 関数が呼び出されます、ここで ViewEnd の位置を計算し直しているようです。

bool AGameplayAbilityTargetActor_Trace::ClipCameraRayToAbilityRange(FVector CameraLocation, FVector CameraDirection, FVector AbilityCenter, float AbilityRange, FVector& ClippedPosition)
{
    FVector CameraToCenter = AbilityCenter - CameraLocation;
    float DotToCenter = FVector::DotProduct(CameraToCenter, CameraDirection);
    if (DotToCenter >= 0)     //If this fails, we're pointed away from the center, but we might be inside the sphere and able to find a good exit point.
    {
        float DistanceSquared = CameraToCenter.SizeSquared() - (DotToCenter * DotToCenter);
        float RadiusSquared = (AbilityRange * AbilityRange);
        if (DistanceSquared <= RadiusSquared)
        {
            float DistanceFromCamera = FMath::Sqrt(RadiusSquared - DistanceSquared);
            float DistanceAlongRay = DotToCenter + DistanceFromCamera;                     //Subtracting instead of adding will get the other intersection point
            ClippedPosition = CameraLocation + (DistanceAlongRay * CameraDirection);        //Cam aim point clipped to range sphere
            return true;
        }
    }
    return false;
}

変数名が変わっているので以下に対応表を書きます。

関数の外 関数内
ViewStart CameraLocation
ViewDir CameraDirection
TraceStart AbilityCenter
MaxRange AbilityRange
ViewEnd ClippedPosition

3次元のベクトルをスクショで表示しようというのが無理がある上に全て位置ベクトルでの表示で余計にややこしいのですが

FVector CameraToCenter = AbilityCenter - CameraLocation;
float DotToCenter = FVector::DotProduct(CameraToCenter, CameraDirection);

この 2 行に関してのみ図示してみます。この場合、AbilityCenter つまり TraceStart はキャラクター前方の Sphere の位置を指定していると考えてください。

まず赤で表される CameraToCenter ですが、AbilityCenterCameraLocation のベクトルの差です。実際には3次元なので見た目の長さや角度に違和感はありますが、以下のような感じになると思います。(ホント、もっと良い見せ方ないのか。。)

続いて DotToCenter ですが、CameraToCenterCameraDirection内積です。CameraDirection は大きさ 1 なので CameraToCenterCameraDirection の方向にした場合の大きさです。
図では黒線で表していますが、DotToCenter は長さなので、CameraDirection の方向に図示しています。例によって 3次元だとよくわからん感はあるのですが、赤線が CameraDirection 方向に射影されたら黒線ぐらいの長さになるというのはなんとなく分かるような分からんような。

続いて if (DotToCenter >= 0) で判定していますが、これが負になるということは、カメラの向きと、カメラ - トレーススタートの向きが 90 度より大きいということなのでほぼ視界外れているか端の方にいるということになりそうなのでここでは false としているのでしょうか。(コメントの意図はよく分かりませんでした 😅)

float DistanceSquared = CameraToCenter.SizeSquared() - (DotToCenter * DotToCenter);
float RadiusSquared = (AbilityRange * AbilityRange);
// 長さの比較
if (DistanceSquared <= RadiusSquared)
{
    float DistanceFromCamera = FMath::Sqrt(RadiusSquared - DistanceSquared);
    float DistanceAlongRay = DotToCenter + DistanceFromCamera;                     //Subtracting instead of adding will get the other intersection point
    ClippedPosition = CameraLocation + (DistanceAlongRay * CameraDirection);        //Cam aim point clipped to range sphere
    return true;
}

まずは長さの比較です。ベクトルが混ざっているので全体的に 2 乗されていますが最終的に長さを比較しています。
まず AbilityRange ですが、これは入力の MaxRange ですので Trace する範囲(ここでは SingleLine なので単純に線の長さ)です。
これと比較するのは CameraToCenterDotToCenter の長さを引いたものです。DotToCenter は前述の通り、単に CameraToCenterCameraDirection 方向にしただけです。

自分でもふわっとした理解になってしまうのですが、DistanceSquared <= RadiusSquared は、まず DistanceSquared は「カメラからトレース開始位置の距離」と「それをカメラの方向を考慮した距離」の差なので、もしカメラがトレース開始位置とは異なる方向を向いていればいる程「その差は大きく」なります。

そして最終的に ClippedPositionCameraLocation に対しての位置を計算します。つまり、上記の「差」が「トレース範囲」よりも大きくなってしまうと「カメラとトレース開始位置の間のみを対象とする」ことになりかねないということだと思います。(実際、この ClippedPositionViewEnd として LineTraceWithFilter のパラメータとして使われます)

DistanceFromCamera がカメラの方向も考慮した上でのトレースする距離を表し、DistanceAlongRay でそれをカメラ方向を考慮したトレース開始位置に足し合わせることで、カメラからの総トレース距離を計算しています。

私個人としては、SingleLineTrace といえば、「キャラクターの前方からトレースして」みたいなことを実装することがほとんどなので、こういうコードを見るのはとても新鮮でした。

再び AimWithPlayerController に戻る

ClipCameraRayToAbilityRange が長くなったので以下に以降の処理を貼り直します。(全部ではありません)

 ClipCameraRayToAbilityRange(ViewStart, ViewDir, TraceStart, MaxRange, ViewEnd);

    FHitResult HitResult;
    LineTraceWithFilter(HitResult, InSourceActor->GetWorld(), Filter, ViewStart, ViewEnd, TraceProfile.Name, Params);

    const bool bUseTraceResult = HitResult.bBlockingHit && (FVector::DistSquared(TraceStart, HitResult.Location) <= (MaxRange * MaxRange));

    const FVector AdjustedEnd = (bUseTraceResult) ? HitResult.Location : ViewEnd;

    FVector AdjustedAimDir = (AdjustedEnd - TraceStart).GetSafeNormal();
    if (AdjustedAimDir.IsZero())
    {
        AdjustedAimDir = ViewDir;
    }

ClipCameraRayToAbilityRangeViewEnd が得られましたので、それを使って LineTraceWithFilter を行っています。ViewStart はカメラの位置です。これを行うために ClipCameraRayToAbilityRange でカメラからトレースする範囲を計算していたんですね。

その結果を受けて、BlockingHit かつ (FVector::DistSquared(TraceStart, HitResult.Location) <= (MaxRange * MaxRange)) でトレース開始位置(カメラ位置ではなく入力ピンから受け取ったトレース開始位置)からヒットした場所の距離と、トレース範囲の距離を比較し、有効範囲であれば bUseTraceResult = true としています。トレース開始位置からではなく、カメラからのトレースなので意図しない場所でヒットしないかをチェックしているのだと思います。

AdjustedEnd には、先程の bUseTraceResult に応じて、ヒットした場所か ViewEnd (ClipCameraRayToAbilityRange で計算したもの) を入れています。

AdjustedAimDirAdjustedEndTraceStart の差の向きのみを取得しています。
そして、あまりに差が小さい場合には ViewDir (カメラ向き) を代わりに利用しています。

AimDir ということは、狙う方向的なことだと思うので、カメラからトレースしてヒットしたベクトルと、トレース開始位置のベクトルの差というのはなるほどという感じです。

AimWithPlayerController の最後のブロックです。
まず、if ブロックを通過しない場合は、先程の AdjustedAimDir を使ってトレース開始位置からトレース距離分 AdjustedAimDir を伸ばしたベクトルを OutTraceEnd としています。

 if (!bTraceAffectsAimPitch && bUseTraceResult)
    {
        FVector OriginalAimDir = (ViewEnd - TraceStart).GetSafeNormal();

        if (!OriginalAimDir.IsZero())
        {
            // Convert to angles and use original pitch
            const FRotator OriginalAimRot = OriginalAimDir.Rotation();

            FRotator AdjustedAimRot = AdjustedAimDir.Rotation();
            AdjustedAimRot.Pitch = OriginalAimRot.Pitch;

            AdjustedAimDir = AdjustedAimRot.Vector();
        }
    }

    OutTraceEnd = TraceStart + (AdjustedAimDir * MaxRange);

bTraceAffectsAimPitch は入力ピンで受け取る値です。Pitch に関してはここまでの結果を使わないとうことでしょうか。

まず、ViewEnd (ClipCameraRayToAbilityRange で取得) と TraceStart (トレース開始位置 ) の差の向きを OriginalAimDir としています。

それが有効な場合には、Pitch のみ AdjustedAimDir から差し替えているようです。(と書いていますが、Vector -> Rotation -> Vector の変換ってよくわかってない。。)

結局 AimWithPlayerController はなんだったのか

結果として受け取るのは OutTraceEnd 、つまり外側の値としては TraceEnd、SingleLineTrace のトレース範囲の端です。

そのために、カメラの向きを考慮したトレース範囲を計算し、それを持って一度 LineTrace を行い、その結果としてヒットが得られればその方向にトレースの方向を調整しています。

よって、私のような素人がよくやる Trace はキャラクターの ForwardVector でみたいなものではなく、カメラの向きを考慮した上でトレースの方向を決めているという感じと個人的には理解しました。

ConfirmTargetingAndContinue

Radius のところでも見ましたが、Trace 系も同様に ConfirmTargetingAndContinueAbilityTask_WaitTargetData の confirm を受け付けます。つまりこれが実行された時点でこの GameplayAbilityTargetActor_SingleLineTrace はデータを出力します。

Tick はあくまで TargetActor 自身の SetActorLocationAndRotation しか更新していません。

void AGameplayAbilityTargetActor_Trace::ConfirmTargetingAndContinue()
{
    check(ShouldProduceTargetData());
    if (SourceActor)
    {
        bDebug = false;
        FGameplayAbilityTargetDataHandle Handle = MakeTargetData(PerformTrace(SourceActor));
        TargetDataReadyDelegate.Broadcast(Handle);
    }
}

FGameplayAbilityTargetDataHandle AGameplayAbilityTargetActor_Trace::MakeTargetData(const FHitResult& HitResult) const
{
    /** Note: This will be cleaned up by the FGameplayAbilityTargetDataHandle (via an internal TSharedPtr) */
    return StartLocation.MakeTargetDataHandleFromHitResult(OwningAbility, HitResult);
}

Tick でも実行されていた PerformTrace を再度実行し、その時点でヒットするデータを FGameplayAbilityTargetDataHandle に詰めた上で delegate に流しています。

まとめ

簡単には、トレース開始位置、範囲、トレース対象のプロファイルを渡した上で、内部では LineTraceByProfile を行い、Confirm 時点でヒットしているデータを渡すというだけのものでした。

加えて、トレース開始位置と範囲だけを渡しているため、トレース終了位置を内部で決定しておりその処理が個人的には面白いものでした。