こんにちは。エンジニアの松沢です。
先日、趣味でリズムゲームのシミュレーターを作ってみようと思い立ちました。
とりあえず入力とノーツのタイミングが合っているかを判定するシステムを作らなければいけません。
EnhancedInputを活用するのもいいのですが、今回はまだまだテスト段階です。EventGraphに入力のノードを置いてまずは入力された時間の表示を…

さて、ここでふと何か違和感を感じました。「これってちゃんと入力したタイミングを取れているの?」と不安がよぎったのです。
言うまでもなくUE5のメイン処理を司るのはゲームスレッドです。この処理もそこで行われます。
ゲームスレッドから次のゲームスレッドまでは60fpsで16.6…7msほどの時間があります。この間にされた入力というのは実際はどのタイミングで処理されるのでしょうか?

答えは次のゲームスレッドです。入力がなされたタイミングで入力情報は一時的に保存され、次のゲームスレッドが来たタイミングで処理されます。

試しに入力されたタイミングを出力する仕組みを複数作ってみましょう。

t.MaxFPSなどでFPSを落として同時押しすると、返ってくる値が全く同じ出力をする場合があることが確認できると思います。ゲームスレッドで処理を完結するために時間がフレーム単位で量子化されているためこういったことが起こります。

つまり、入力の判定は最大1フレーム分遅延する可能性があります。ほとんどのゲームにおいて1フレームという単位は充分に細かく、フレーム単位の粒度で判定するこのシステムは問題なく機能するでしょう。しかし、一部のゲームではちょっとだけ問題があったりします。
例えば最近のリズムゲームなどでは1ms単位などで判定を調整できるオプションが実装されていたりします。そんな中でそもそも最大1フレーム(16.6…7ms)入力の判定がズレる可能性がある、というのはちょっと許容しにくいのではないでしょうか。
入力がなされた本当に正確な時間--とまではいきませんが、せめてゲームスレッドで処理する際にもう少し細かい粒度で入力があった時間を検知できないものでしょうか。入力に合わせてその入力がなされた正確な時間が返ってくるような仕組みを作れば、処理そのものがゲームスレッドで行われていても判定の計算はできるはずです。そう、例えばこんな形です。

肝心の正確な時間情報はどこから手に入れられるでしょうか?ここではゲームとは別の非同期スレッドで高速なループを回し、その中でOS側から入力情報を入手することにします。
入力がいつされるかマシン側は分かりません。なので、入力を感知するには1つのスレッドを常に占有し続けるようなポーリング処理をする必要があります。Unreal Engineにはいくつか非同期のシステムがありますが、こういった役割にはFRunnableを活用するのが最適です。今回は1000Hzの頻度でループを回し続けます。概念図としては以下のようになります。

早速作ってみましょう。入力情報の取り出し口となるクラスはGameInstanceSubsystemを継承して作成します。


まずはC++のゲームスレッドとBP間の入力情報を受け取る仕組みを作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
// HighPrecisionInputSubsystem.h #pragma once #include "CoreMinimal.h" #include "Subsystems/GameInstanceSubsystem.h" #include "HighPrecisionInputSubsystem.generated.h" // BPにて入力情報を受け取るためのデリゲート DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnHighPrecisionKeyInput, int32, KeyCode, double, ExactSeconds); UCLASS() class INPUTSAMPLE_API UHighPrecisionInputSubsystem : public UGameInstanceSubsystem { GENERATED_BODY() public: // Push入力があった場合の通知 UPROPERTY(BlueprintAssignable, Category = "Input|HighPrecision") FOnHighPrecisionKeyInput OnHighPrecisionKeyPress; // Release入力があった場合の通知 UPROPERTY(BlueprintAssignable, Category = "Input|HighPrecision") FOnHighPrecisionKeyInput OnHighPrecisionKeyRelease; }; |
これでこのようにバインドによってSubsystemからBPへ情報が渡せるようになりました。

ただし、このデリゲートが発火するのはゲームスレッドにおけるTick()のタイミングです。SubsystemはデフォルトではTick()を利用できないので、FTickableGameObjectを継承させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
// HighPrecisionInputSubsystem.h #pragma once #include "CoreMinimal.h" #include "Subsystems/GameInstanceSubsystem.h" #include "Tickable.h" // FTickableGameObjectを継承するために必要 #include "HighPrecisionInputSubsystem.generated.h" // BPにて入力情報を受け取るためのデリゲート DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnHighPrecisionKeyInput, int32, KeyCode, double, ExactSeconds); UCLASS() class INPUTSAMPLE_API UHighPrecisionInputSubsystem : public UGameInstanceSubsystem, public FTickableGameObject { GENERATED_BODY() public: virtual void Tick(float DeltaTime) override; virtual TStatId GetStatId() const override; // Push入力があった場合の通知 UPROPERTY(BlueprintAssignable, Category = "Input|HighPrecision") FOnHighPrecisionKeyInput OnHighPrecisionKeyPress; // Release入力があった場合の通知 UPROPERTY(BlueprintAssignable, Category = "Input|HighPrecision") FOnHighPrecisionKeyInput OnHighPrecisionKeyRelease; }; |
|
|
// HighPrecisionInputSubsystem.cpp #include "HighPrecisionInputSubsystem.h" void UHighPrecisionInputSubsystem::Tick(float DeltaTime) {} TStatId UHighPrecisionInputSubsystem::GetStatId() const { RETURN_QUICK_DECLARE_CYCLE_STAT(UHighPrecisionInputSubsystem, STATGROUP_Tickables); } |
一旦、Tick()の中身は書かず、FRunnableを継承したポーリングワーカーを作成します。今回は以下のようなワーカーを作成しています。
|
|
// HighPrecisionInputSubsystem.h // ...前略 // // スレッドからメインに渡すためのデータ構造体 struct FThreadedInputEvent { int32 KeyCode; double ExactOSSeconds; }; // 後略... // |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
|
// HighPrecisionInputSubsystem.cpp #include "HighPrecisionInputSubsystem.h" #include "HAL/RunnableThread.h" #include "HAL/PlatformProcess.h" #include "HAL/PlatformTime.h" #include "Engine/World.h" #include "Framework/Application/SlateApplication.h" #if PLATFORM_WINDOWS #include "Windows/AllowWindowsPlatformTypes.h" #include "windows.h" #include "Windows/HideWindowsPlatformTypes.h" #endif class FInputPollingWorker : public FRunnable // 別スレッドで動作するポーリング処理のワーカー { private: TQueue<FThreadedInputEvent, EQueueMode::Spsc>* PressEventQueuePtr; // 押された情報を保存するためのTQueueポインタ TQueue<FThreadedInputEvent, EQueueMode::Spsc>* ReleaseEventQueuePtr; // 離された情報を保存するためのTQueueポインタ FThreadSafeBool bStopThread; bool KeyStates[256]; public: FInputPollingWorker(TQueue<FThreadedInputEvent, EQueueMode::Spsc>* InPressQueue, TQueue< FThreadedInputEvent, EQueueMode::Spsc>* InReleaseQueue) : PressEventQueuePtr(InPressQueue), ReleaseEventQueuePtr(InReleaseQueue), bStopThread(false) { FMemory::Memzero(KeyStates, sizeof(KeyStates)); } virtual uint32 Run() override { while (!bStopThread) { #if PLATFORM_WINDOWS // 仮想キーコードの8(Backspace)-255を監視 // パフォーマンス面を考えて監視するキーコードを設定できるようにしても良いかも for (int32 Key = 8; Key < 256; ++Key) { short State = GetAsyncKeyState(Key); // ハードウェア状態を直接取得 bool bIsDown = (State & 0x8000) != 0; // 最上位ビットが立っていれば押されていると考えられる // 離し→押し状態に変化した瞬間を判定 if (bIsDown && !KeyStates[Key]) { FThreadedInputEvent Event{}; Event.KeyCode = Key; // 押されたキーを記録 Event.ExactOSSeconds = FPlatformTime::Seconds(); // プラットフォームの時間を記録 PressEventQueuePtr->Enqueue(Event); // キューに格納 } // こちらは押し→離し状態に変化した瞬間を判定 else if (!bIsDown && KeyStates[Key]) { FThreadedInputEvent Event{}; Event.KeyCode = Key; Event.ExactOSSeconds = FPlatformTime::Seconds(); ReleaseEventQueuePtr->Enqueue(Event); } // 現在のキーの状態を保存 KeyStates[Key] = bIsDown; } #endif // 約1ms(1000Hz)の休止 FPlatformProcess::Sleep(0.001f); } return 0; } virtual void Stop() override { bStopThread = true; } }; // 後略... // |
見ての通り、コンストラクタで2つのTQueueのポインタを受け取り、ポーリングで検知した押し・離し入力とOSから入手したタイミング情報を合わせてTQueue内に格納します。1ms単位でループしているので、これで1000Hzのポーリングが可能です。
さて、Subsystem側に必要なプロパティを作成していきます。各プロパティは以下の通りです。
- FInputPollingWorkerのポインタ
- FInputPollingWorkerを起動させるためのFRunnableThreadのポインタ
- 押し・離し入力を保存するための2つのEventQueue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
// HighPrecisionInputSubsystem.h #pragma once #include "CoreMinimal.h" #include "Subsystems/GameInstanceSubsystem.h" #include "Tickable.h" // FTickableGameObjectを継承するために必要 #include "Containers/Queue.h" // TQueueを使うために必要 #include "HighPrecisionInputSubsystem.generated.h" // スレッドクラスはcpp側で定義するため、こちらは前方宣言 class FInputPollingWorker; // 中略...// // ...中略 // private: // スレッド関連のポインタ FInputPollingWorker* PollingWorker = nullptr; FRunnableThread* RunnableThread = nullptr; // スレッドセーフなキュー(ModeはSpsc) TQueue<FThreadedInputEvent, EQueueMode::Spsc> PressEventQueue; // 押し入力の格納先 TQueue<FThreadedInputEvent, EQueueMode::Spsc> ReleaseEventQueue; // 離し入力の格納先 }; |
今度はTick()内で行われる処理を書いていきましょう。ここで行うことは主に3つです。
- TQueueに記録された情報を展開して、キーコードをUnrealのFKey情報に変換する
- 同時に記録された時間からゲーム内時間を計算する
- それらの情報をBroadcast()してBP側に伝える
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
|
// HighPrecisionInputSubsystem.cpp void UHighPrecisionInputSubsystem::Tick(float DeltaTime) { if ((!PressEventQueue.IsEmpty() || !ReleaseEventQueue.IsEmpty()) && GetWorld()) { // 現在のOS時間 double CurrentOSSeconds = FPlatformTime::Seconds(); // ワールドの経過時間 double CurrentWorldTime = GetWorld()->GetRealTimeSeconds(); FThreadedInputEvent Event{}; // バックグラウンドで反応して欲しくはないので、スレートがアクティブな場合に限り // キューに溜まっている入力をすべて取り出す if (FSlateApplication::IsInitialized() && FSlateApplication::Get().IsActive()) { while (PressEventQueue.Dequeue(Event)) { // 現在のOS時間から記録されたOS時間を引けば、記録からどのぐらい時間がたっているのか判明する double DelaySeconds = CurrentOSSeconds - Event.ExactOSSeconds; // ワールド時間から遅延分を引いて、押された瞬間の正確なワールド時間を逆算 double ExactWorldTime = CurrentWorldTime - DelaySeconds; OnHighPrecisionKeyPress.Broadcast(Event.KeyCode, ExactWorldTime); } while (ReleaseEventQueue.Dequeue(Event)) { double DelaySeconds = CurrentOSSeconds - Event.ExactOSSeconds; double ExactWorldTime = CurrentWorldTime - DelaySeconds; OnHighPrecisionKeyRelease.Broadcast(Event.KeyCode, ExactWorldTime); } } else { // アクティブでない場合は入力情報を破棄 PressEventQueue.Empty(); ReleaseEventQueue.Empty(); } } } |
最後にSubsystemのInitializeでワーカーの作成とFRunnableThreadの起動を行い、Deinitializeで安全に終了できるようにしたら作業完了です。
|
|
// HighPrecisionInputSubsystem.h UCLASS() class INPUTSAMPLE_API UHighPrecisionInputSubsystem : public UGameInstanceSubsystem, public FTickableGameObject { GENERATED_BODY() public: virtual void Initialize(FSubsystemCollectionBase& Collection) override; virtual void Deinitialize() override; // 後略... // |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
// HighPrecisionInputSubsystem.cpp void UHighPrecisionInputSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); // ワーカーの生成とスレッドの起動 (優先度を最高に設定) PollingWorker = new FInputPollingWorker(&PressEventQueue, &ReleaseEventQueue); RunnableThread = FRunnableThread::Create(PollingWorker, TEXT("HighPrecisionInputPoller"), 0, TPri_Highest); } void UHighPrecisionInputSubsystem::Deinitialize() { // 安全なスレッドの終了と破棄 if (PollingWorker) { PollingWorker->Stop(); if (RunnableThread) { RunnableThread->WaitForCompletion(); delete RunnableThread; RunnableThread = nullptr; } delete PollingWorker; PollingWorker = nullptr; } Super::Deinitialize(); } |
最終的にはこのようなコードとなりました。
[code]
では、検証してみましょう。
先ほどと同じようにt.MaxFPSでFPSを落とし、同時押し入力をしてみます。
[pic]
きちんと機能しているようですね。
今回ご紹介した方法はEventTickよりもはるかに高頻度でスレッドをループさせるものです。1000Hz程度のポーリング処理で問題になる場合は少ないですが、活用する際にはCPUに相応の負荷がかかるのでパフォーマンスにはご注意ください。
また、この方法はOSからキー入力を直接取る関係上、プラットフォーム依存の方法となっております。Windows以外の環境で動作させるにはまた別途実装が必要になりますのでそちらもご了承ください。
それでは良きUEライフを。