Unreal Engine 4 (UE4) #2 Advent Calendar 2019 22日目の記事になります。
はじめに
本記事は、移動コンポーネントを自作するにあたって何か参考になるものないかなー。。と、
試しにGoogle検索してみた人や、学生等の初学者向けになります。
前提として、プレイヤーの入力をコントローラーを通じて操作対象のポーンに
伝えるまでの方法を習得しているものと考えます。(BPでもC++でも、どちらでも可)
検証にはUnreal Engine 4.23.1およびVisual Studio Community 2019 ver16.1.1を使用しました。
参考資料
公式ドキュメント
Pawn Movement コンポーネントの挙動のコーディング
https://docs.unrealengine.com/ja/Programming/Tutorials/Components/3/index.html
スライド
UE4を用いたTPS制作事例 EDF:IR 地球を衛る兵士の歩き方
https://www.slideshare.net/EpicGamesJapan/ue4tps-edfir-187546688
移動コンポーネントを作る
今回はUPawnMovementComponentクラスを継承して作ります。
Unreal C++を多少組んだことのある方なら分かるであろう部分は省略させて頂いています。
UCLASS()
class UMyPawnMovementComponent : public UPawnMovementComponent
{
GENERATED_BODY()
public:
UMyPawnMovementComponent(const FObjectInitializer& ObjectInitializer = 中略);
public:
void TickComponent(float DeltaTime, 中略) overide;
};
とりあえず移動させてみる
ただ移動すること自体はそんなに難しくはありません。
今回は以下のような流れで組んでみました。実装は結構簡略化しています。
1.操作するPawnを経由して入力量を設定する
2.入力量から速度を計算して、向きベクトルと速度から移動速度を得る
3.移動速度から移動量を求め、移動量の分だけ移動する
1.入力量を設定する
移動させるPawnから移動方向の入力量を設定できるようにしておきます。
自作したPlayer Controllerなどから上手いこと入力量を移動コンポーネントに渡してください。
public:
//前方向の入力量を設定する
UFUNCTION(BlueprintCallable)
void SetInputAmount_MoveForward(float InputAmount) {
MoveInputAmount.Y = InputAmount;
}
//右方向の入力量を設定する
UFUNCTION(BlueprintCallable)
void SetInputAmount_MoveRight(float InputAmount) {
MoveInputAmount.X = InputAmount;
}
private:
//速度計算で使う移動の入力量
FVector MoveInputAmount;
2.入力量から速度を計算して、向きベクトルと速度から移動速度を得る
説明のために細かい実装等を略していますが、今回は加速だけでなく減速を考慮した計算を行っています。
まず移動方向は気にせず、現在の速度だけを求めます。
その後、向きベクトルを得て速度を反映して移動速度を得ています。
移動方向を決める向きベクトルを更新対象のコンポーネントから取得します。
説明を省略するために速度計算をラムダ式で組みましたが、関数でも問題ありません。
void UMyPawnMovementComponent::TickComponent(省略)
{
//Update Velocity
{
//加速減速最高速、速度に関するパラメータ。省略していますが初速もあるとなお良いと思います。
//説明の関係で定数にしていますが、この手の値は調整される値になるハズなので外部設定できるようにします。
const float MAX_MOVE_ACCELERATION = 2360.0f;
const float MAX_MOVE_DECELERATION = 2360.0f;
const float MAX_MOVE_SPEED = 584.0f;
//ラムダ:加速時の速度計算
auto calcAccSpeed = [=](float inputAmount, float nowSpeed) {
const float maxSpeed = FMath::Abs(MAX_MOVE_SPEED * inputAmount);
nowSpeed += MAX_MOVE_ACCELERATION * DeltaTime * inputAmount;
return FMath::Clamp(nowSpeed, -maxSpeed, maxSpeed);
};
//ラムダ:減速時の速度計算
auto calcDecSpeed = [=](float nowSpeed) {
if (0.0f < nowSpeed) {
float calcSpeed = nowSpeed - (MAX_MOVE_DECELERATION * DeltaTime);
return FMath::Clamp(calcSpeed, 0.0f, nowSpeed);
}
else if (0.0f > nowSpeed) {
float calcSpeed = nowSpeed + (MAX_MOVE_DECELERATION * DeltaTime);
return FMath::Clamp(calcSpeed, nowSpeed, 0.0f);
}
return nowSpeed;
};
//前後方向の速度計算
if (0.0f < FMath::Abs(MoveInputAmount.Y)) {
ForwardNowSpeed = calcAccSpeed(MoveInputAmount.Y, ForwardNowSpeed);
}
else {
ForwardNowSpeed = calcDecSpeed(ForwardNowSpeed);
}
//左右方向の速度計算
if (0.0f < FMath::Abs(MoveInputAmount.X)) {
RightNowSpeed = calcAccSpeed(MoveInputAmount.X, RightNowSpeed);
}
else {
RightNowSpeed = calcDecSpeed(RightNowSpeed);
}
//移動速度の更新
{
const auto rotation = UpdatedComponent->GetComponentRotation();
const auto forwardVector = UKismetMathLibrary::GetForwardVector(rotation);
const auto rightVector = UKismetMathLibrary::GetRightVector(rotation);
Velocity = (forwardVector * ForwardNowSpeed) + (rightVector * RightNowSpeed);
}
}
}
3.移動速度から移動量を求め、移動量の分だけ移動する
計算で得られた移動速度から、フレーム毎の移動量を求めます。
1フレームあたりの移動量の分だけ、移動します。
移動量が非常に小さい(ほぼ0の)場合は停止しているとみなして移動しません。
MoveUpdatedComponent関数に移動量と向きを渡すことで移動します。第3引数をtrueにすることで、
衝突判定が行われて移動先に壁などの障害物があった場合に障害物にめり込まない場所まで押し戻します。
逆にfalseにすると衝突判定を行わないので壁にあたってもすり抜けます。
void UMyPawnMovementComponent::TickComponent(省略)
{
//Update Velocity
省略
//Update Movement
{
FVector moveVec = Velocity * DeltaTime;
if (!moveVec.IsNearlyZero())
{
MoveUpdatedComponent(moveVec, UpdatedComponent->GetComponentQuat(), true);
}
UpdateComponentVelocity();
}
}
ジャンプしてみる
ジャンプも前述の移動とほぼ内容は変わりません。
違いがあるのは、この計算は初速のみで加速がありません。重力による減速のみです。
まずはジャンプできるようにして、ジャンプ中(落下中)かどうか分かるようにします。
public:
//ジャンプする
UFUNCTION(BlueprintCallable)
void Jump();
private:
//落下しているかどうか
bool IsFalling;
void UMyPawnMovementComponent::Jump()
{
//ジャンプした時の初速度
//説明の関係で定数にしていますが、この手の値は調整される値になるハズなので外部設定できるようにします。
const float INIT_JUMP_SPEED = 930.0f;
UpNowSpeed = INIT_JUMP_SPEED;
//ジャンプしたので落下状態になる。
//今回フラグでやりましたが、EMovementModeの様にEnumにするのもアリかと思います。
IsFalling = true;
}
速度の計算を行い、移動速度を求めます。
void UMyPawnMovementComponent::TickComponent(省略)
{
//Update Velocity
省略
//Update Gravity
if (IsFalling)
{
//落下中の最高速度
//説明の関係で定数にしていますが、この手の値は調整される値になるハズなので外部設定できるようにします。
const float MAX_FALL_SPEED = 5000.0f;
const float GRAVITY_SCALE = 2.0f;
UpNowSpeed += GetGravityZ() * GRAVITY_SCALE * DeltaTime;
UpNowSpeed = FMath::Clamp(UpNowSpeed, -MAX_FALL_SPEED, MAX_FALL_SPEED);
{
const auto rotation = UpdatedComponent->GetComponentRotation();
const auto upVector = UKismetMathLibrary::GetUpVector(rotation);
Velocity += (upVector * UpNowSpeed);
}
}
//Update Movement
省略
}
着地判定を行います。
今回は雑に移動した後で当たり判定の結果からBlocking Hitとなったコンポーネントの「上に乗れる」フラグを見て判断しています。
実際にはこの判定だけだとジャンプ中に壁に当たった時も「着地」してしまうので、別途工夫が必要です。
void UMyPawnMovementComponent::TickComponent(省略)
{
//Update Velocity
省略
//Update Gravity
省略
//Update Movement
{
FVector moveVec = Velocity * DeltaTime;
if (!moveVec.IsNearlyZero())
{
FHitResult hitResult;
MoveUpdatedComponent(moveVec, UpdatedComponent->GetComponentRotation(), true, &hitResult);
if (hitResult.bBlockingHit)
{
//落下中の着地判定
if (IsFalling)
{
const auto hitComponent = hitResult.GetComponent();
if (hitComponent)
{
if (hitComponent->CanCharacterStepUp(nullptr))
{
IsFalling = false;
UpNowSpeed = 0.0f;
}
}
}
}
}
UpdateComponentVelocity();
}
}
うぅ。。スコープが深い。。
ジャンプできるようになったので、何かの上に乗ることができるようになりました。
こうなると当然、乗った何かの上から落下できるようにする必要があるのですが、それは後ほど。。
壁に当たってる時にスライドさせてみる
移動するようになったと思いますが、壁に当たると停止してしまい移動しなくなります。
壁に対して垂直に当たったならそうでしょうが、大抵の場合は壁の無いほうにスライドして移動は継続します。
これに対応するためには、MoveUpdatedComponentを使って移動したあと、
障害物にヒットしていたならAlongSlideSurface関数にResultを渡して移動処理させるのが楽です。
void UMyPawnMovementComponent::TickComponent(省略)
{
//Update Velocity
省略
//Update Gravity
省略
//Update Movement
{
FVector moveVec = Velocity * DeltaTime;
if (!moveVec.IsNearlyZero())
{
FHitResult hitResult;
MoveUpdatedComponent(moveVec, UpdatedComponent->GetComponentRotation(), true, &hitResult);
if (hitResult.bBlockingHit)
{
//壁などにヒットした場合に停止せずにスライドさせる処理
FHitResult hitSlide = hitResult;
SlideAlongSurface(moveVec, 1.0f - hitResult.Time, hitResult.Normal, hitSlide, true);
//落下中の着地判定
省略
}
}
UpdateComponentVelocity();
}
}
この辺りは公式ドキュメントにもありましたが、SlideAlongSurface関数を使うことで簡単にスライドさせることが出来ます。
またSlideAlongSurface関数の実装は複雑ではありませんので、オーバーライドして拡張するのも容易です。
移動中の落下
先に説明したジャンプでボックス等の上に乗ることが出来るようになりましたが、その上を移動していると
そのまま空中を移動してしまう事がわかると思います。ジャンプすると落下しますが、移動中に落下させるには
床に設置しているかどうかを判断する必要があります。そりゃそうだ。。
void UMyPawnMovementComponent::TickComponent(省略)
{
省略
//Update Movement
{
省略
{
省略
if (hitResult.bBlockingHit)
{
省略
}
else
{
//移動中の落下判定
if (!IsFalling)
{
FHitResult hitFall;
//下向きにトレースして、床面に接触していないなら落下させる。
{
const auto capsule = Cast<UCapsuleComponent>(UpdatedComponent);
if (capsule)
{
const auto radius = capsule->GetScaledCapsuleRadius();
const auto halfHeight = capsule->GetScaledCapsuleHalfHeight();
const auto rotation = UpdatedComponent->GetComponentRotation();
const auto upVector = UKismetMathLibrary::GetUpVector(rotation);
FVector traceStartPos = UpdatedComponent->GetComponentLocation();
FVector traceEndPos = traceStartPos - (upVector * halfHeight);
TArray<TEnumAsByte<EObjectTypeQuery>> traceObjectTypeArray;
traceObjectTypeArray.Add(EObjectTypeQuery::ObjectTypeQuery1);//WorldStatic
traceObjectTypeArray.Add(EObjectTypeQuery::ObjectTypeQuery2);//WorldDynamic
TArray<AActor*> traceIgnoreActorArray;
UKismetSystemLibrary::CapsuleTraceSingleForObjects(this, traceStartPos, traceEndPos, radius, halfHeight,
traceObjectTypeArray, false, traceIgnoreActorArray, EDrawDebugTrace::None, hitFall, true);
}
}
//床に接触していないので落下する
if (!hitFall.bBlockingHit)
{
IsFalling = true;
}
}
}
}
省略
}
}
この様に、適当な方法で浮いてるかどうかを判断します。
上記の実装だとUpdatedComponentがカプセルコンポーネントである前提になってしまいます。
まだUE4に慣れていない頃に考えたものなので、もっといい方法があるかもしれません。
坂道を降る
移動中の落下によって、ボックスの上からは落下するようになりましたが、いかがでしょうか?
そのままの状態では、坂道を登ることは出来ても降ることがうまく出来ないと思います。
現状だと坂道を登るのはSlideAlongSurface関数の処理内でうまいこと行われますが、降りは
障害物との接触でスライドする訳ではありませんので別途対応が必要になります。
移動中の落下処理に下記の処理を追加します。
void UMyPawnMovementComponent::TickComponent(省略)
{
省略
//Update Movement
{
省略
{
省略
if (hitResult.bBlockingHit)
{
省略
}
else
{
//移動中の落下判定
if (!IsFalling)
{
FHitResult hitFall;
//下向きにトレースして、床面に接触していないなら落下させる。
省略
//床に接触していないので落下する
if (!hitFall.bBlockingHit)
{
IsFalling = true;
}
else
{
//傾斜ではなく落下する段差として認識する高さ。
//説明の関係で定数にしていますが、この手の値は調整される値になるハズなので外部設定できるようにします。
const float FALL_STEP_HEIGHT = 20.0f;
//段差からは落下させる
if (FALL_STEP_HEIGHT < hitFall.Distance)
{
IsFalling = true;
}
//傾斜を降る移動処理。登る移動処理はSlideAlongSurface関数の処理で行われます。
else
{
FVector moveVecFall = hitFall.ImpactPoint - UpdatedComponent->GetComponentLocation();
MoveUpdatedComponent(moveVecFall, UpdatedComponent->GetComponentQuat(), true);
}
}
}
}
}
省略
}
}
MoveUpdatedComponent関数をもう一度呼ぶことになるので、処理負荷が気になる場合は工夫が必要かもしれません。
少なくとも、ScopedMovementUpdateクラスを使って移動時の各種更新処理が呼ばれる頻度は調整出来ると思います。
とりあえずここまで
ここまでの実装で、なんとなく移動に必要な要素が入っていると思いますが足りないものが沢山あります。
例えばジャンプ中に壁に当たると壁に着地する筈です。壁判定がないから。。天井に頭を打っても。。
などなど、実際にゲームなどで使えるようにするには拡張や工夫がいくつか必要です。
基本的にはUE4側に優秀な移動コンポーネントがありますので、それで事足りる場合が多いと思いますが
そこで出来ないことを要求されることもありますので、無理にUE4側の機能を使うでもなく、無理に自作するでもなく、
いい感じのバランスで取捨選択出来ると良いかなと思う次第です。
さいごに、今回説明に使ったプロジェクトをまとめたものを共有しておきます。
https://drive.google.com/open?id=1ndN-S2oqSE_xIaGSEyuw1EOccV7HIVHm
おわりに
元々予定していた内容より、だいぶ薄い内容になってしまいました。。
断片的な手法の紹介になってしまいましたが、何かしら参考になれば幸いです。
明日は@Aquaria901Cさんによる「GUIネタで書きまーす!Niagaraも使うかも」です。