適応性機能をネイティブ ゲームに統合する

1. はじめに

70748189a60ed450.png

適応性機能をゲームに統合する必要がある理由

適応性 API を使用すると、アプリの実行中にデバイスの状態に関するフィードバックを取得して、ワークロードを動的に調整し、ゲームのパフォーマンスを最適化できます。また、ワークロードをシステムに伝え、システムで最適なリソースが割り当てられるようにできます。

作成するアプリの概要

この Codelab では、ネイティブ Android サンプルゲームを起動して実行し、適応性機能をゲームに統合します。必要なコードをセットアップして追加した後で、それまでのゲームと適応性機能を追加したバージョンとの間での認識されるパフォーマンスの違いをテストできます。

学習内容

  • Thermal API を既存のゲームに統合して、熱ステータスに適応しデバイスの過熱を防ぐ方法。
  • Game Mode API を統合して、選択内容の変更に対応する方法。
  • Game State API を統合して、ゲーム実行中の状態をシステムに伝える方法。
  • Performance Hint API を統合して、スレッドモデルとワークロードをシステムに伝える方法。

必要なもの

2. セットアップする

開発環境をセットアップする

Android Studio でネイティブ プロジェクトを扱ったことがない場合は、Android NDK と CMake のインストールが必要な場合があります。すでにインストールされている場合は、「プロジェクトをセットアップする」に進みます。

SDK、NDK、CMake がインストールされていることを確認する

Android Studio を起動します。[Welcome to Android Studio] ウィンドウが表示されたら、[Configure] プルダウン メニューを開き、[SDK Manager] オプションを選択します。

3b7b47a139bc456.png

既存のプロジェクトをすでに開いている場合は、代わりに [Tools] メニューから [SDK Manager] を開けます。[Tools] メニューをクリックして、[SDK Manager] を選択すると、[SDK Manager] ウィンドウが開きます。

サイドバーで、[Appearance & Behavior] > [System Settings] > [Android SDK] の順に選択します。[Android SDK] ペインで [SDK Platforms] タブを選択すると、インストール済みのツール オプションのリストが表示されます。Android SDK 12.0 以降がインストールされていることを確認します。

931f6ae02822f417.png

次に、[SDK Tools] タブを選択し、[NDK] と [CMake] がインストールされていることを確認します。

注: バージョンが比較的新しいものであれば具体的なバージョンは問いません。現在の最新バージョンは NDK 25.2.9519653 と CMake 3.24.0 です。デフォルトでインストールされる NDK のバージョンは、今後の NDK リリースに合わせて順次変更されます。特定のバージョンの NDK をインストールする必要がある場合は、Android Studio リファレンスの特定バージョンの NDK をインストールするセクションの下にある NDK のインストールの手順に沿って操作してください。

d28adf9279adec4.png

必要なツールのチェックボックスをすべてオンにしたら、ウィンドウの下にある [Apply] ボタンをクリックしてインストールします。その後 [OK] ボタンをクリックして、[Android SDK] ウィンドウを閉じます。

プロジェクトをセットアップする

このサンプル プロジェクトは、OpenGL 用 Swappy で開発されたシンプルな 3D 物理シミュレーション ゲームです。テンプレートから作成された新しいプロジェクトと比較して、ディレクトリ構造に大きな変更はありません。ただし、物理およびレンダリング ループを初期化する作業がいくつかあるため、続いてリポジトリのクローンを作成します。

リポジトリのクローンを作成する

コマンドラインから、ルートゲーム ディレクトリを含めるディレクトリに変更し GitHub からクローンを作成します。

git clone -b codelab/start https://github.com/android/adpf-game-adaptability-codelab.git --recurse-submodules

[codelab] start: simple game というリポジトリの最初の commit から開始していることを確認します。

依存関係をセットアップする

サンプル プロジェクトでは、ユーザー インターフェースに Dear ImGui ライブラリを使用します。また、3D 物理シミュレーション用に Bullet Physics も使用しています。これらのライブラリはプロジェクトのルートの third_party ディレクトリにあることが前提です。先ほどの clone コマンドで指定した --recurse-submodules で、それぞれのライブラリを確認しています。

プロジェクトをテストする

Android Studio で、ディレクトリのルートからプロジェクトを起動します。デバイスが接続されていることを確認し、[Build] > [Make Project][Run] > [Run 'app'] を選択してデモをテストします。デバイスでの最終的な結果は次のようになります。

f1f33674819909f1.png

プロジェクトについて

ゲームは意図的にシンプルなもので、適応性機能の実装の詳細に焦点を絞っています。簡単に構成できる物理およびグラフィック ワークロードを実行しているため、実行時にデバイス状態の変化に合わせて構成を動的に調整できます。

3. Thermal API を統合する

a7e07127f1e5c37d.png

ce607eb5a2322d6b.png

Java で熱ステータスの変化をリッスンする

Android 10(API レベル 29)以降、Android デバイスは熱ステータスが変化するたびに、実行中のアプリに報告する必要があります。アプリは PowerManager に OnThermalStatusChangedListener を指定して、この変更をリッスンできます。

PowerManager.addThermalStatusListener は API レベル 29 以降でのみ使用できるため、呼び出す前に確認する必要があります。

// CODELAB: ADPFSampleActivity.java onResume
if (Build.VERSION.SDK_INT >= VERSION_CODES.Q) {
   PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
      if (pm != null) {
         pm.addThermalStatusListener(this);
      }
}

API レベル 30 以降では、AThermal_registerThermalStatusListener を使って C++ コードでコールバックを登録できるため、次のようにネイティブ メソッドを定義して Java から呼び出せます。

// CODELAB: ADPFSampleActivity.java onResume
if (Build.VERSION.SDK_INT >= VERSION_CODES.R) {
   // Use NDK Thermal API.
   nativeRegisterThermalStatusListener();
}

アクティビティの onResume ライフサイクル関数にリスナーを追加する必要があります。

アクティビティの onResume に追加したものはすべて、アクティビティの onPause で削除する必要もあります。そのため、PowerManager.removeThermalStatusListenerAThermal_unregisterThermalStatusListener を呼び出すクリーンアップ コードを定義します。

// CODELAB: ADPFSampleActivity.java onPause
// unregister the listener when it is no longer needed
// Remove the thermal state change listener on pause.
if (Build.VERSION.SDK_INT >= VERSION_CODES.R) {
   // Use NDK Thermal API.
   nativeUnregisterThermalStatusListener();
} else if (Build.VERSION.SDK_INT >= VERSION_CODES.Q) {
   PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
   if (pm != null) {
      pm.removeThermalStatusListener(this);
   }
}

これらの関数を ADPFManager.java に移して抽象化し、他のプロジェクトで簡単に再利用できるようにしましょう。

ゲームのアクティビティ クラスで、ADPFManager のインスタンスを作成して保持し、対応するアクティビティのライフサイクル メソッドで熱リスナーの追加または削除を接続します。

// CODELAB: ADPFSampleActivity.java
// Keep a copy of ADPFManager instance
private ADPFManager adpfManager;

// in onCreate, create an instance of ADPFManager
@Override
protected void onCreate(Bundle savedInstanceState) {
   // Instantiate ADPF manager.
   this.adpfManager = new ADPFManager();
   super.onCreate(savedInstanceState);
}

@Override
protected void onResume() {
   // Register ADPF thermal status listener on resume.
   this.adpfManager.registerListener(getApplicationContext());
   super.onResume();
}

@Override
protected void onPause() {
   // Remove ADPF thermal status listener on resume.
  this.adpfManager.unregisterListener(getApplicationContext());
   super.onPause();
}

JNI_OnLoad に C++ クラスのネイティブ メソッドを登録する

API レベル 30 以降では、NDK Thermal API AThermal_* を使用でき、Java リスナーをマッピングして、同じ C++ メソッドを呼び出せます。Java メソッドで C++ コードを呼び出すには、JNI_OnLoad に C++ メソッドを登録する必要があります。詳しくは、JNI に関するヒントをご覧ください。

// CODELAB: android_main.cpp
// Remove the thermal state change listener on pause.
// Register classes to Java.
jint JNI_OnLoad(JavaVM *vm, void * /* reserved */) {
  JNIEnv *env;
  if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
    return JNI_ERR;
  }

  // Find your class. JNI_OnLoad is called from the correct class loader context
  // for this to work.
  jclass c = env->FindClass("com/android/example/games/ADPFManager");
  if (c == nullptr) return JNI_ERR;

  // Register your class' native methods.
  static const JNINativeMethod methods[] = {
      {"nativeThermalStatusChanged", "(I)V",
       reinterpret_cast<void *>(nativeThermalStatusChanged)},
      {"nativeRegisterThermalStatusListener", "()V",
       reinterpret_cast<void *>(nativeRegisterThermalStatusListener)},
      {"nativeUnregisterThermalStatusListener", "()V",
       reinterpret_cast<void *>(nativeUnregisterThermalStatusListener)},
  };
  int rc = env->RegisterNatives(c, methods,
                                sizeof(methods) / sizeof(JNINativeMethod));

  if (rc != JNI_OK) return rc;

  return JNI_VERSION_1_6;
}

ネイティブ リスナーをゲームに接続する

C++ ゲームは対応する熱ステータスが変化したことを把握する必要があります。そのために C++ で対応する adpf_manager クラスを作成します。

アプリソースの cpp フォルダ($ROOT/app/src/main/cpp)に、adpf_manager.h ファイルと adpf_manager.cpp ファイルのペアを作成します

// CODELAB: adpf_manager.h
// Forward declarations of functions that need to be in C decl.
extern "C" {
   void nativeThermalStatusChanged(JNIEnv* env, jclass cls, int32_t thermalState);
   void nativeRegisterThermalStatusListener(JNIEnv* env, jclass cls);
   void nativeUnregisterThermalStatusListener(JNIEnv* env, jclass cls);
}

typedef void (*thermalStateChangeListener)(int32_t, int32_t);

ADPFManager クラス以外の cpp ファイルで C 関数を定義します。

// CODELAB: adpf_manager.cpp
// Native callback for thermal status change listener.
// The function is called from Activity implementation in Java.
void nativeThermalStatusChanged(JNIEnv *env, jclass cls, jint thermalState) {
  ALOGI("Thermal Status updated to:%d", thermalState);
  ADPFManager::getInstance().SetThermalStatus(thermalState);
}

void nativeRegisterThermalStatusListener(JNIEnv *env, jclass cls) {
  auto manager = ADPFManager::getInstance().GetThermalManager();
  if (manager != nullptr) {
    auto ret = AThermal_registerThermalStatusListener(manager, thermal_callback,
                                                      nullptr);
    ALOGI("Thermal Status callback registered to:%d", ret);
  }
}

void nativeUnregisterThermalStatusListener(JNIEnv *env, jclass cls) {
  auto manager = ADPFManager::getInstance().GetThermalManager();
  if (manager != nullptr) {
    auto ret = AThermal_unregisterThermalStatusListener(
        manager, thermal_callback, nullptr);
    ALOGI("Thermal Status callback unregistered to:%d", ret);
  }
}

サーマル ヘッドルームの取得に必要な PowerManager と関数を初期化する

API レベル 30 以降では、NDK Thermal API AThermal_* を使用できるため、初期化時に AThermal_acquireManager を呼び出して、今後のために保存できます。API レベル 29 では、必要な Java の参照を見つけて保持する必要があります。

// CODELAB: adpf_manager.cpp
// Initialize JNI calls for the powermanager.
bool ADPFManager::InitializePowerManager() {
  if (android_get_device_api_level() >= 30) {
    // Initialize the powermanager using NDK API.
    thermal_manager_ = AThermal_acquireManager();
    return true;
  }

  JNIEnv *env = NativeEngine::GetInstance()->GetJniEnv();

  // Retrieve class information
  jclass context = env->FindClass("android/content/Context");

  // Get the value of a constant
  jfieldID fid =
      env->GetStaticFieldID(context, "POWER_SERVICE", "Ljava/lang/String;");
  jobject str_svc = env->GetStaticObjectField(context, fid);

  // Get the method 'getSystemService' and call it
  jmethodID mid_getss = env->GetMethodID(
      context, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;");
  jobject obj_power_service = env->CallObjectMethod(
      app_->activity->javaGameActivity, mid_getss, str_svc);

  // Add global reference to the power service object.
  obj_power_service_ = env->NewGlobalRef(obj_power_service);

  jclass cls_power_service = env->GetObjectClass(obj_power_service_);
  get_thermal_headroom_ =
      env->GetMethodID(cls_power_service, "getThermalHeadroom", "(I)F");

  // Free references
  env->DeleteLocalRef(cls_power_service);
  env->DeleteLocalRef(obj_power_service);
  env->DeleteLocalRef(str_svc);
  env->DeleteLocalRef(context);

  if (get_thermal_headroom_ == 0) {
    // The API is not supported in the platform version.
    return false;
  }

  return true;
}

初期化メソッドが呼び出されることを確認する

このサンプルでは、初期化メソッドは SetApplication から呼び出され、SetApplication は android_main から呼び出されます。このセットアップはフレームワークに固有のものであるため、自分のゲームに統合するときには、初期化メソッドを呼び出す適切な場所を判断する必要があります。

// CODELAB: adpf_manager.cpp
// Invoke the API first to set the android_app instance.
void ADPFManager::SetApplication(android_app *app) {
  app_.reset(app);

  // Initialize PowerManager reference.
  InitializePowerManager();
}
// CODELAB: android_main.cpp
void android_main(struct android_app *app) {
  std::shared_ptr<NativeEngine> engine(new NativeEngine(app));

  // Set android_app to ADPF manager, which in turn will call InitializePowerManager
  ADPFManager::getInstance().SetApplication(app);

  ndk_helper::JNIHelper::Init(app);

  engine->GameLoop();
}

サーマル ヘッドルームを定期的にモニタリングする

通常、ワークロードを完全に一時停止することなく温度を下げることは難しいため、熱ステータスが高くなりすぎないようにすることをおすすめします。完全にシャットダウンした後でも、デバイスが放熱して冷却するまでにしばらく時間がかかります。定期的にサーマル ヘッドルームを確認して、ワークロードを調整することによって、ヘッドルームを抑制し、熱ステータスの上昇を防止できます。

ADPFManager でサーマル ヘッドルームを確認するメソッドを公開しましょう。

// CODELAB: adpf_manager.cpp
// Invoke the method periodically (once a frame) to monitor
// the device's thermal throttling status.
void ADPFManager::Monitor() {
  float current_clock = Clock();
  if (current_clock - last_clock_ >= kThermalHeadroomUpdateThreshold) {
    // Update thermal headroom.
    UpdateThermalStatusHeadRoom();
    last_clock_ = current_clock;
  }
}
// CODELAB: adpf_manager.cpp
// Retrieve current thermal headroom using JNI call.
float ADPFManager::UpdateThermalStatusHeadRoom() {
  if (android_get_device_api_level() >= 30) {
    // Use NDK API to retrieve thermal status headroom.
    thermal_headroom_ = AThermal_getThermalHeadroom(
        thermal_manager_, kThermalHeadroomUpdateThreshold);
    return thermal_headroom_;
  }

  if (app_ == nullptr || get_thermal_headroom_ == 0) {
    return 0.f;
  }
  JNIEnv *env = NativeEngine::GetInstance()->GetJniEnv();

  // Get thermal headroom!
  thermal_headroom_ =
      env->CallFloatMethod(obj_power_service_, get_thermal_headroom_,
                           kThermalHeadroomUpdateThreshold);
  ALOGE("Current thermal Headroom %f", thermal_headroom_);
  return thermal_headroom_;
}

最後に、熱ステータスとそのリスナーを設定するメソッドを公開する必要があります。NDK の Thermal API または Java SDK からネイティブ コードを呼び出して、熱ステータスの値を取得します。

// CODELAB: adpf_manager.cpp
thermalStateChangeListener thermalListener = NULL;

void ADPFManager::SetThermalStatus(int32_t i) {
  int32_t prev_status_ = thermal_status_;
  int32_t current_status_ = i;
  thermal_status_ = i;
  if ( thermalListener != NULL ) {
    thermalListener(prev_status_, current_status_ );
  }
}

void ADPFManager::SetThermalListener(thermalStateChangeListener listener)
{
  thermalListener = listener;
}

CMakeLists.txt のコンパイル単位に adpf_manager.cpp を含める

忘れずに新しく作成した adpf_manager.cpp をコンパイル単位に追加してください。

これで、再利用可能な ADPFManager java クラスと cpp クラスの作業が完了しました。グルーコードを再度記述することなく、これらのファイルを取得して、他のプロジェクトで再利用できます。

// CODELAB: CMakeLists.txt
# now build app's shared lib
add_library(game SHARED
        adpf_manager.cpp # add this line
        android_main.cpp
        box_renderer.cpp
        demo_scene.cpp

熱ステータス悪化時に変更するゲーム内パラメータを定義する

これ以降の部分はゲームに固有のものです。この例では、熱ステータス上昇時に物理ステップとボックス数を削減します。

また、サーマル ヘッドルームもモニタリングしますが、HUD に値を表示する以外の対応はしません。ご自身のゲームではその値に対応するため、グラフィック カードによる後処理の量を調整したり、詳細レベルを引き下げたりできます。

// CODELAB: demo_scene.cpp
// String labels that represents thermal states.
const char* thermal_state_label[] = {
    "THERMAL_STATUS_NONE",     "THERMAL_STATUS_LIGHT",
    "THERMAL_STATUS_MODERATE", "THERMAL_STATUS_SEVERE",
    "THERMAL_STATUS_CRITICAL", "THERMAL_STATUS_EMERGENCY",
    "THERMAL_STATUS_SHUTDOWN"};

const int32_t thermal_state_physics_steps[] = {
        16, 12, 8, 4,
};
const int32_t thermal_state_array_size[] = {
        8, 6, 4, 2,
};

ゲームに熱ステータス変更リスナー関数を作成する

次に ADPFManager がデバイスの熱レベルの変化を検出すると呼び出す cpp thermalListener を作成する必要があります。ゲームにこの関数を作成して、変化したステータスの値をリッスンします。last_stateをトラッキングして、熱レベルの上昇または低下を把握します。

// CODELAB: demo_scene.cpp
// Dedicate a function to listen to the thermal state changed
void DemoScene::on_thermal_state_changed(int32_t last_state, int32_t current_state)
{
  if ( last_state != current_state ) {
    demo_scene_instance_->AdaptThermalLevel(current_state);
  }
}

...

// remember to pass it to the ADPFManager class we've just created, place this in DemoScene constructor: 
ADPFManager::getInstance().SetThermalListener(on_thermal_state_changed);

ゲームの熱ステータスの変化に合わせて調整する

ゲームによってニーズや優先順位は異なり、あるゲームにおいては非常に重要なことが別のゲームでは重要でない場合があります。そのため、さらに温度が上昇しないように最適化する方法は自身で判断する必要があります。

この例では、画面上のオブジェクトの数を減らし、物理忠実度を下げています。これにより、CPU および GPU ワークロードの両方が軽減し、若干の温度低下が期待できます。通常、プレーヤーが休憩を取りデバイスを冷却しない限り、温度を大幅に低下させることは困難です。そのため、サーマル ヘッドルームを注意深くモニタリングし、デバイスがサーマル スロットリング状態にならないようにする必要があります。

// CODELAB: demo_scene.cpp
// Adapt your game when the thermal status has changed
void DemoScene::AdaptThermalLevel(int32_t index) {
  int32_t current_index = index;
  int32_t array_size = sizeof(thermal_state_physics_steps) / sizeof(thermal_state_physics_steps[0]);
  if ( current_index < 0 ) {
    current_index = 0;
  } else if ( current_index >= array_size ) {
    current_index = array_size - 1;
  }

  ALOGI("AdaptThermalLevel: %d", current_index);

  // in this sample, we are reducing the physics step when the device heated
  current_physics_step_ = thermal_state_physics_steps[current_index];
  // and also reduce the number of objects in the world
  // your situation may be different, you can reduce LOD to remove some calculations for example...
  int32_t new_array_size_ = thermal_state_array_size[current_index];

  if ( new_array_size_ != array_size_ ) {
      recreate_physics_obj_ = true;
  }
}

また、CPU チップと GPU チップによる最大のパフォーマンスは通常非効率的です。つまり、チップが最大のパフォーマンスを発揮するときには、通常エネルギー消費が非常に大きくなり、大量の熱を発生させます。これに対して、持続的なパフォーマンスはエネルギー消費量と放散される熱量単位あたりのパフォーマンスが最適となります。詳しくは、Android オープンソース プロジェクトのパフォーマンス管理をご覧ください。

プロジェクトをビルドして実行すると、現在の熱ステータスとサーマル ヘッドルームが表示されます。熱ステータスが悪化している場合は、物理ステップとオブジェクトの数が削減されます。

4bdcfe567fc603c0.png

問題が発生している場合は、作業中のものと [codelab] step: integrated thermal-api というリポジトリの commit を比較します。

4. Game Mode API を統合する

Game Mode API を使用すると、ユーザーの選択内容に沿って、ゲームのパフォーマンスまたはバッテリー駆動時間を最適化できます。一部の Android 12 デバイスとすべての Android 13 以降のデバイスで利用できます。

Android マニフェストを更新する

appCategory を設定する

Game Mode API を使用するには、アプリのカテゴリがゲームである必要があるため、<application> タグでそれを示してください。

// CODELAB: AndroidManifest.xml
<application
   android:appCategory="game">

Android 13 以降の場合

次の 2 つのサブステップに沿って、Android 13 のユーザーをターゲットとすることをおすすめします。

game_mode_config <meta-data> と対応する xml ファイルを追加する

// CODELAB: AndroidManifest.xml
   <!-- ENABLING GAME MODES -->
   <!-- Add this <meta-data> under your <application> tag to enable Game Mode API if you're targeting API Level 33 (recommended) -->
   <meta-data android:name="android.game_mode_config"
        android:resource="@xml/game_mode_config" />
// CODELAB: app/src/main/res/xml/game_mode_config.xml
<?xml version="1.0" encoding="UTF-8"?>
<game-mode-config
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:supportsBatteryGameMode="true"
    android:supportsPerformanceGameMode="true" />

Android 12 デバイスをターゲットとしている場合

各 gamemode <meta-data> を AndroidManifest に直接追加する

// CODELAB: AndroidManifest.xml
   <!-- Use meta-data below instead if you are targeting API Level 31 or 32; you do not need to apply the 2 steps prior -->
   <meta-data android:name="com.android.app.gamemode.performance.enabled"
      android:value="true"/>
   <meta-data
      android:name="com.android.app.gamemode.battery.enabled"
      android:value="true"/>

GameModeManager.java を実装し、GameMode 機能を抽象化する

Game Mode API には cpp インターフェースがまだないため、Java インターフェースを使用して JNI インターフェースを提供する必要があります。GameModeManager.java で抽象化し、他のプロジェクトでこの機能を再利用できるようにします。

// CODELAB: GameModeManager.java
// Abstract the functionality in GameModeManager so we can easily reuse in other games
public class GameModeManager {

    private Context context;
    private int gameMode;

    public void initialize(Context context) {
        this.context = context;
        this.gameMode = GameManager.GAME_MODE_UNSUPPORTED;
        if ( context != null ) {
            if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ) {
                // Get GameManager from SystemService
                GameManager gameManager = context.getSystemService(GameManager.class);

                // Returns the selected GameMode
                gameMode = gameManager.getGameMode();
            }
        }
        // tell the native game about the selected gameMode
        this.retrieveGameMode(this.gameMode);
    }

    protected native void retrieveGameMode(int gameMode);
}

Activity を適応させて GameModeManager を初期化し、onResume で GameMode を取得する

アクティビティ ライフサイクルに接続します。ゲームモードが変更されるたびに Activity が再起動され、onResume 時に値を取得できるようになります。

// CODELAB: ADPFSampleActivity.java
// we may keep and cache the object as class member variable
private GameModeManager gameModeManager;

...

@Override
protected void onCreate(Bundle savedInstanceState) {
   ...
   // Instantiate our GameModeManager
   this.gameModeManager = new GameModeManager();
   ...
   super.onCreate(savedInstanceState);
}

...

@Override
protected void onResume() {
   ...
   this.gameModeManager.initialize(getApplicationContext());
   ...
   super.onResume();
}

GameModeManager クラスを実装して、ユーザーが選択した gameMode を保存し、ゲーム内で取得できるようにする

cpp ラッパーを作成し、ゲームモードの値を cpp に保存して簡単に取得できるようにします。

// CODELAB: game_mode_manager.h
class GameModeManager {
 public:
  // Singleton function.
  static GameModeManager& getInstance() {
    static GameModeManager instance;
    return instance;
  }
  // Dtor. Remove global reference (if any).
  ~GameModeManager() {}

  // Delete copy constructor since the class is used as a singleton.
  GameModeManager(GameModeManager const&) = delete;
  void operator=(GameModeManager const&) = delete;

  void SetGameMode(int game_mode) { game_mode_ = game_mode; }
  int GetGameMode() { return game_mode_; }

 private:
  // Ctor. It's private since the class is designed as a singleton.
  GameModeManager() {}

  int game_mode_ = 0;
};

retrieveGameMode をネイティブ コードに実装して、GameMode 値をゲームに渡す

これは最も簡単で効率的な方法です。開始時にゲームモードの値を取得し、簡単にアクセスできるようにそれを cpp 変数に渡します。毎回 JNI 呼び出しを行うことなく、キャッシュに保存された値を利用できます。

// CODELAB: game_mode_manager.cpp
extern "C" {

void Java_com_android_example_games_GameModeManager_retrieveGameMode(
    JNIEnv* env, jobject obj, jint game_mode) {
  GameModeManager& gmm = GameModeManager::getInstance();
  int old_game_mode = gmm.GetGameMode();
  ALOGI("GameMode updated from %d to:%d", old_game_mode, game_mode);
  GameModeManager::getInstance().SetGameMode(game_mode);
}

}

CMakeLists.txt のコンパイル単位に game_mode_manager.cpp を含める

忘れずに新しく作成した game_mode_manager.cpp をコンパイル単位に追加してください。

これで、再利用可能な GameModeManager java クラスと cpp クラスの作業が完了しました。グルーコードを再度記述することなく、これらのファイルを取得して、他のプロジェクトで再利用できます。

// CODELAB: CMakeLists.txt
# now build app's shared lib
add_library(game SHARED
        game_mode_manager.cpp # add this line
        android_main.cpp
        box_renderer.cpp
        demo_scene.cpp

ユーザーが選択した GameMode に沿ってゲームを調整する

ゲームモードを取得後、ユーザーが選択した値に応じてゲームを変化させる必要があります。最も大きな違い(そして最も対立する価値)は、パフォーマンス モードとバッテリー モードの間にあります。パフォーマンス モードでは、通常ユーザーはバッテリー駆動時間を気にすることなく最善のエクスペリエンスでゲームに没頭したいと考えています。そのため、フレームレートが安定している限りは、最善の忠実度を提供します。バッテリー モードでは、ユーザーは長い間ゲームをプレイしたいと考えており、低位の設定を許容できます。フレームレートが不安定な場合、プレーヤーのエクスペリエンスは大幅に低下するため、フレームレートが常に安定していることを確認します。

// CODELAB: demo_scene.cpp
void DemoScene::RenderPanel() {
  ...
  GameModeManager& game_mode_manager = GameModeManager::getInstance();
  // Show the stat changes according to selected Game Mode
  ImGui::Text("Game Mode: %d", game_mode_manager.GetGameMode());
}
// CODELAB: native_engine.h
// Add this function to NativeEngine class, we're going to check the gameMode and set the preferred frame rate and resolution cap accordingly
void CheckGameMode();

このサンプルのパフォーマンス モードでは、レンダリングを最大解像度と 60 FPS に設定しています。これは非常にシンプルな例であり、ほとんどのデバイスはフル FPS でスムーズに実行できるため、これ以上のチェックは適用せずにシンプルにしています。バッテリー モードでは、レンダリングを 30 FPS と 4 分の 1 の解像度に制限しています。自分のアプリにとって最適な値を見つけて最適化する必要があります。ゲームプレイのエクスペリエンスと省電力は、FPS と解像度のみによらないことを常に念頭に置いてください。最適化のアイデアについては、デベロッパーによる成功事例をご覧ください。

// CODELAB: native_engine.cpp
void NativeEngine::CheckGameMode() {
  GameModeManager &gameModeManager = GameModeManager::getInstance();
  int game_mode = gameModeManager.GetGameMode();
  if (game_mode != mGameMode) {
    // game mode changed, make necessary adjustments to the game
    // in this sample, we are capping the frame rate and the resolution
    // we're also hardcoding configs on the engine 🫣
    SceneManager *sceneManager = SceneManager::GetInstance();
    NativeEngine *nativeEngine = NativeEngine::GetInstance();
    int native_width = nativeEngine->GetNativeWidth();
    int native_height = nativeEngine->GetNativeHeight();
    int preferred_width;
    int preferred_height;
    int32_t preferredSwapInterval = SWAPPY_SWAP_30FPS;
    if (game_mode == GAME_MODE_STANDARD) {
      // GAME_MODE_STANDARD : fps: 30, res: 1/2
      preferredSwapInterval = SWAPPY_SWAP_30FPS;
      preferred_width = native_width / 2;
      preferred_height = native_height / 2;
    } else if (game_mode == GAME_MODE_PERFORMANCE) {
      // GAME_MODE_PERFORMANCE : fps: 60, res: 1/1
      preferredSwapInterval = SWAPPY_SWAP_60FPS;
      preferred_width = native_width;
      preferred_height = native_height;
    } else if (game_mode == GAME_MODE_BATTERY) {
      // GAME_MODE_BATTERY : fps: 30, res: 1/4
      preferred_height = SWAPPY_SWAP_30FPS;
      preferred_width = native_width / 4;
      preferred_height = native_height / 4;
    } else {  // game_mode == 0 : fps: 30, res: 1/2
      // GAME_MODE_UNSUPPORTED
      preferredSwapInterval = SWAPPY_SWAP_30FPS;
      preferred_width = native_width / 2;
      preferred_height = native_height / 2;
    }
    ALOGI("GameMode SetPreferredSizeAndFPS: %d, %d, %d", preferred_width,
          preferred_height, preferredSwapInterval);
    sceneManager->SetPreferredSize(preferred_width, preferred_height);
    sceneManager->SetPreferredSwapInterval(preferredSwapInterval);
    mGameMode = game_mode;
  }
}
// CODELAB: native_engine.cpp
void NativeEngine::DoFrame() {
  ...

  // here, we are checking on every frame for simplicity
  // but you can hook it to your onResume callback only
  // as gameMode changes will trigger Activity restart
  CheckGameMode();
  SwitchToPreferredDisplaySize();

  ...
}

Gradle ビルドファイルで忘れずに sdkVersions を確認する

Game Mode API は Android 13 以降のすべての Android デバイスで利用できます。一部の Android 12 デバイスでもこの機能は有効になります。

// CODELAB: app/build.gradle
android {
    compileSdk 33
    ...

    defaultConfig {
        minSdkVersion 30 
        targetSdkVersion 33 // you can use 31 if you're targeting Android 12 and follow the Note section above
        ...
    }

問題が発生している場合は、作業中のものと [codelab] step: integrate game-mode-api というリポジトリの commit を比較します。

496c76415d12cbed.png

5. Game State API を統合する

Game State API を使用すると、システムにゲームのトップレベル状態を通知でき、一時停止できないゲームのプレイを邪魔することなく、現在のコンテンツが中断可能かどうかを伝えられます。また、ディスクやネットワークからのアセットの読み込みなど、負荷の高い I/O がゲームで現在行われているかも示せます。システムがこうしたデータをすべて把握することで、適切なタイミングで適切なリソースを割り当てられます(たとえば、プレーヤーが重要なマルチプレーヤー型ゲームのセッション中に、マルチプレーヤー型ゲームのネットワーク トラフィックを読み込んだり、優先したりする場合、適切な量の CPU とメモリ帯域幅を割り当てます)。

GameState 定数へのマッピングを容易にする列挙型を定義する

Game State API には cpp インターフェースがないため、まず値を cpp にコピーします。

// CODELAB: game_mode_manager.h
enum GAME_STATE_DEFINITION {
    GAME_STATE_UNKNOWN = 0,
    GAME_STATE_NONE = 1,
    GAME_STATE_GAMEPLAY_INTERRUPTIBLE = 2,
    GAME_STATE_GAMEPLAY_UNINTERRUPTIBLE = 3,
    GAME_STATE_CONTENT = 4,
};

JNI で Java API を呼び出す cpp コードに SetGameState を定義する

cpp 関数さえあれば、すべての情報を Java API に渡せます。

// CODELAB: game_mode_manager.h
void SetGameState(bool is_loading, GAME_STATE_DEFINITION game_state);

負担に感じる必要はありません。JNI 呼び出しは 1 度のみです。

// CODELAB: game_mode_manager.cpp
void GameModeManager::SetGameState(bool is_loading,
                                   GAME_STATE_DEFINITION game_state) {
  if (android_get_device_api_level() >= 33) {
    ALOGI("GameModeManager::SetGameState: %d => %d", is_loading, game_state);

    JNIEnv* env = NativeEngine::GetInstance()->GetJniEnv();

    jclass cls_gamestate = env->FindClass("android/app/GameState");

    jmethodID ctor_gamestate =
        env->GetMethodID(cls_gamestate, "<init>", "(ZI)V");
    jobject obj_gamestate = env->NewObject(
        cls_gamestate, ctor_gamestate, (jboolean)is_loading, (jint)game_state);

    env->CallVoidMethod(obj_gamemanager_, gamemgr_setgamestate_, obj_gamestate);

    env->DeleteLocalRef(obj_gamestate);
    env->DeleteLocalRef(cls_gamestate);
  }
}

ゲームプレイの状態が変わるたびに SetGameState を呼び出す

読み込みの開始、読み込みの停止、ゲーム内のさまざまな状態の開始や終了など、適切なタイミングで cpp 関数を呼び出します。

// CODELAB: welcome_scene.cpp
void WelcomeScene::OnInstall() {
  // 1. Game State: Start Loading
  GameModeManager::getInstance().SetGameState(true, GAME_STATE_NONE);
}
// CODELAB: welcome_scene.cpp
void WelcomeScene::OnStartGraphics() {
  // 2. Game State: Finish Loading, showing the attract screen which is interruptible
  GameModeManager::getInstance().SetGameState(
      false, GAME_STATE_GAMEPLAY_INTERRUPTIBLE);
}
// CODELAB: welcome_scene.cpp
void WelcomeScene::OnKillGraphics() {
  // 3. Game State: exiting, cleaning up and preparing to load the next scene
  GameModeManager::getInstance().SetGameState(true, GAME_STATE_NONE);
}

その時点で状態が不明な場合は UNKNOWN を渡しても問題ありません。このケースでは、シーンをアンロードし、ユーザーが次に向かう先はわかりませんが、すぐに次のシーンが読み込まれ、既知の新しい状態で別の SetGameState を呼び出します。

// CODELAB: welcome_scene.cpp
void WelcomeScene::OnUninstall() {
  // 4. Game State: Finished unloading this scene, it will be immediately followed by loading the next scene
  GameModeManager::getInstance().SetGameState(false, GAME_STATE_UNKNOWN);
}

簡単ですよね。Game State API を統合すると、システムはアプリの状態を把握して、プレーヤーにとってパフォーマンスと効率性が向上するようにリソースの最適化を開始します。

引き続き Game State API の次回のアップデートをご確認ください。次回はラベルと品質でゲームをさらに最適化する方法を紹介します。

6. Performance Hint API を統合する

Performance Hint API を使用すると、特定のタスクを担当するスレッド グループごとにセッションを作成して、システムにパフォーマンス向上のヒントを送信でき、初期の目標処理時間を設定して、各フレームで実際の処理時間を報告し、次のフレームで予想される処理時間を更新します。

たとえば、敵の AI、物理計算、レンダリング スレッドを担当するスレッドのグループがあるとします。これらのサブタスクはすべてフレームごとに完了する必要があります。サブタスクのいずれかでオーバーランが発生すると、フレームが遅延し、目標の FPS を達成できなくなります。このスレッドのグループの PerformanceHint Session を作成して、targetWorkDuration を目標の FPS に設定できます。各フレームでの処理の後に reportActualWorkDuration を指定すると、システムはこの傾向を分析し、それに応じて CPU リソースを調整して、設定した目標をすべてのフレームで達成できるようにします。これにより、フレームの安定性が向上し、ゲームの消費電力を効率化できます。

ADPFManager InitializePerformanceHintManager

スレッドのヒント セッションを作成する必要があります。C++ API もありますが、使用できるのは API レベル 33 以降のみとなります。API レベル 31 と 32 では Java API を使用する必要があります。後で使えるように、JNI メソッドをキャッシュに保存しましょう。

// CODELAB: adpf_manager.cpp
// Initialize JNI calls for the PowerHintManager.
bool ADPFManager::InitializePerformanceHintManager() {
  #if __ANDROID_API__ >= 33
    if ( hint_manager_ == nullptr ) {
        hint_manager_ = APerformanceHint_getManager();
    }
    if ( hint_session_ == nullptr && hint_manager_ != nullptr ) {
        int32_t tid = gettid();
        thread_ids_.push_back(tid);
        int32_t tids[1];
        tids[0] = tid;
        hint_session_ = APerformanceHint_createSession(hint_manager_, tids, 1, last_target_);
    }
    ALOGI("ADPFManager::InitializePerformanceHintManager __ANDROID_API__ 33");
    return true;
#else  
  ALOGI("ADPFManager::InitializePerformanceHintManager __ANDROID_API__ < 33");
  JNIEnv *env = NativeEngine::GetInstance()->GetJniEnv();

  // Retrieve class information
  jclass context = env->FindClass("android/content/Context");

  // Get the value of a constant
  jfieldID fid = env->GetStaticFieldID(context, "PERFORMANCE_HINT_SERVICE",
                                       "Ljava/lang/String;");
  jobject str_svc = env->GetStaticObjectField(context, fid);

  // Get the method 'getSystemService' and call it
  jmethodID mid_getss = env->GetMethodID(
      context, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;");
  jobject obj_perfhint_service = env->CallObjectMethod(
      app_->activity->javaGameActivity, mid_getss, str_svc);

  // Add global reference to the power service object.
  obj_perfhint_service_ = env->NewGlobalRef(obj_perfhint_service);

  // Retrieve methods IDs for the APIs.
  jclass cls_perfhint_service = env->GetObjectClass(obj_perfhint_service_);
  create_hint_session_ =
      env->GetMethodID(cls_perfhint_service, "createHintSession",
                       "([IJ)Landroid/os/PerformanceHintManager$Session;");
  jmethodID mid_preferedupdaterate = env->GetMethodID(
      cls_perfhint_service, "getPreferredUpdateRateNanos", "()J");

  // Create int array which contain current tid.
  jintArray array = env->NewIntArray(1);
  int32_t tid = gettid();
  env->SetIntArrayRegion(array, 0, 1, &tid);
  const jlong DEFAULT_TARGET_NS = 16666666;

  // Create Hint session for the thread.
  jobject obj_hintsession = env->CallObjectMethod(
      obj_perfhint_service_, create_hint_session_, array, DEFAULT_TARGET_NS);
  if (obj_hintsession == nullptr) {
    ALOGI("Failed to create a perf hint session.");
  } else {
    obj_perfhint_session_ = env->NewGlobalRef(obj_hintsession);
    preferred_update_rate_ =
        env->CallLongMethod(obj_perfhint_service_, mid_preferedupdaterate);

    // Retrieve mid of Session APIs.
    jclass cls_perfhint_session = env->GetObjectClass(obj_perfhint_session_);
    report_actual_work_duration_ = env->GetMethodID(
        cls_perfhint_session, "reportActualWorkDuration", "(J)V");
    update_target_work_duration_ = env->GetMethodID(
        cls_perfhint_session, "updateTargetWorkDuration", "(J)V");
    set_threads_ = env->GetMethodID(
        cls_perfhint_session, "setThreads", "([I)V");
  }

  // Free local references
  env->DeleteLocalRef(obj_hintsession);
  env->DeleteLocalRef(array);
  env->DeleteLocalRef(cls_perfhint_service);
  env->DeleteLocalRef(obj_perfhint_service);
  env->DeleteLocalRef(str_svc);
  env->DeleteLocalRef(context);

  if (report_actual_work_duration_ == 0 || update_target_work_duration_ == 0) {
    // The API is not supported in the platform version.
    return false;
  }

  return true;
#endif // __ANDROID_API__ >= 33

}

ADPFManager::SetApplication で呼び出す

android_main から定義した初期化関数を呼び出します。

// CODELAB: adpf_manager.cpp
// Invoke the API first to set the android_app instance.
void ADPFManager::SetApplication(android_app *app) {
  ...

  // Initialize PowerHintManager reference.
  InitializePerformanceHintManager();
}
// CODELAB: android_main.cpp
void android_main(struct android_app *app) {
  ...

  // Set android_app to ADPF manager & call InitializePerformanceHintManager
  ADPFManager::getInstance().SetApplication(app);

  ...
}

ADPFManager::BeginPerfHintSession と ADPFManager::EndPerfHintSession を定義する

必要なすべてのパラメータを受け取り、JNI から実際に API を呼び出す cpp メソッドを定義します。

// CODELAB: adpf_manager.h
// Indicates the start and end of the performance intensive task.
// The methods call performance hint API to tell the performance
// hint to the system.
void BeginPerfHintSession();
void EndPerfHintSession(jlong target_duration_ns);
// CODELAB: adpf_manager.cpp
// Indicates the start and end of the performance intensive task.
// The methods call performance hint API to tell the performance hint to the system.
void ADPFManager::BeginPerfHintSession() { 
  perf_start_ = std::chrono::high_resolution_clock::now(); 
}

void ADPFManager::EndPerfHintSession(jlong target_duration_ns) {
#if __ANDROID_API__ >= 33
    auto perf_end = std::chrono::high_resolution_clock::now();
    auto dur = std::chrono::duration_cast<std::chrono::nanoseconds>(perf_end - perf_start_).count();
    int64_t actual_duration_ns = static_cast<int64_t>(dur);
    APerformanceHint_reportActualWorkDuration(hint_session_, actual_duration_ns);
    APerformanceHint_updateTargetWorkDuration(hint_session_, target_duration_ns);
#else
  if (obj_perfhint_session_) {
    auto perf_end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::nanoseconds>(perf_end - perf_start_).count();
    int64_t duration_ns = static_cast<int64_t>(duration);
    JNIEnv *env = NativeEngine::GetInstance()->GetJniEnv();

    // Report and update the target work duration using JNI calls.
    env->CallVoidMethod(obj_perfhint_session_, report_actual_work_duration_,
                        duration_ns);
    env->CallVoidMethod(obj_perfhint_session_, update_target_work_duration_,
                        target_duration_ns);
  }
#endif // __ANDROID_API__ >= 33

  }
}

各フレームの開始時と終了時に呼び出す

各フレームにおいて、フレームの開始時点で開始時間を記録し、フレームの終了時点で実際の時間を報告する必要があります。フレームの終了時点で reportActualWorkDurationupdateTargetWorkDuration の両方を呼び出します。このシンプルな例の場合、フレーム間は変更せず、一貫性のある目標値で updateTargetWorkDuration を更新します。

// CODELAB: demo_scene.cpp
void DemoScene::DoFrame() {
  // Tell ADPF manager beginning of the perf intensive task.
  ADPFManager::getInstance().BeginPerfHintSession();

  ...
  
  // Tell ADPF manager end of the perf intensive tasks.
  ADPFManager::getInstance().EndPerfHintSession(jlong target_duration_ns);
}

7. 完了

これで、正常に適応性機能をゲームに統合できました。

今後も Android に適応性フレームワークからさらに機能を追加していきますので、引き続きご確認ください。