Integrating Adaptability features into your Native Game

1. Introduction

70748189a60ed450.png

Why do I need to integrate Adaptability features in my game?

Adaptability APIs allow you to get feedback about the state of the device during runtime of your application and let you adjust your workload dynamically to optimize the performance of your game. It also allows you to tell the system about your workload so that the system can allocate resources optimally.

What you'll build

In this codelab, you're going to open a native Android sample game, run it, and integrate Adaptability features with it. After setting up and adding the necessary code, you'll be able to test the difference in perceived performance between the prior game and the version with Adaptability.

What you'll learn

  • How to integrate Thermal API into an existing game & adapt to thermal status to prevent overheating.
  • How to integrate Game Mode API & react to selection changes.
  • How to integrate Game State API to let the system know what state the game is running in.
  • How to integrate Performance Hint API to let the system know your threading model and workload.

What you'll need

2. Getting set up

Setting up your development environment

If you have not previously worked with native projects in Android Studio, you may need to install Android NDK and CMake. If you already have them installed, proceed to Setting up the project.

Checking that the SDK, NDK and CMake is installed

Launch Android Studio. When the Welcome to Android Studio window is displayed, open the Configure dropdown menu and select the SDK Manager option.

3b7b47a139bc456.png

If you already have an existing project opened, you can instead open the SDK Manager via the Tools menu. Click on Tools menu and select SDK Manager, the SDK Manager window will open.

In the sidebar, select in order: Appearance & Behavior > System Settings > Android SDK. Select the SDK Platforms tab in the Android SDK pane to display a list of installed tool options. Make sure that Android SDK 12.0 or later is installed.

931f6ae02822f417.png

Next, select the SDK Tools tab and make sure that NDK and CMake is installed.

Note: The exact version shouldn't matter as long as they're reasonably new, but we're currently on NDK 25.2.9519653 and CMake 3.24.0. The version of the NDK being installed by default will change over time with subsequent NDK releases. If you need to install a specific version of the NDK, follow the instructions in the Android Studio reference for installing the NDK under the section "Install a specific version of the NDK".

d28adf9279adec4.png

Once all the required tools are checked, click the Apply button at the bottom of the window to install them. You may then close the Android SDK window by clicking the Ok button.

Setting up the project

The example project is a simple 3D physics simulation game developed with Swappy for OpenGL. There is not much change in directory structure compared with the new project created from the template but there is some work done to initialize the physics and rendering loop, so go ahead and clone the repo instead.

Cloning the repo

From the command line, change to the directory you wish to contain the root game directory and clone it from GitHub:

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

Make sure that you're starting from the initial commit of the repo titled [codelab] start: simple game.

Setting up the dependencies

The sample project uses the Dear ImGui library for its user interface. It also uses Bullet Physics for 3D physics simulations. These libraries are assumed to exist in the third_party directory located at the root of the project. We have checked out the respective libraries via --recurse-submodules specified in our clone command above.

Test the project

In Android Studio, open the project from the root of the directory. Make sure that a device is connected, then select Build > Make Project and Run > Run ‘app' to test the demo. The end result on device should look like this

f1f33674819909f1.png

About the project

The game is intentionally minimalistic to focus on the specifics of implementing Adaptability features. It is running some easily configurable physics and graphics workload so that we can adapt the configuration dynamically during runtime as the device condition changes.

3. Integrate Thermal API

a7e07127f1e5c37d.png

ce607eb5a2322d6b.png

Listen to Thermal Status changed in Java

Since Android 10 (API Level 29), Android devices have to report to the running application whenever the thermal status changes. Applications can listen to this change by providing OnThermalStatusChangedListener to PowerManager.

Since PowerManager.addThermalStatusListener is only available on API Level 29 onwards, we need to put a check before calling it:

// 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);
      }
}

And on API Level 30 onwards, you can register the callback in C++ code using AThermal_registerThermalStatusListener thus you can define a native method and call it from Java like this:

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

You will need to add the listeners in your Activity's onResume lifecycle function.

Remember that everything you add to your Activity's onResume will also need to be removed in your Activity's onPause. So let's define our cleanup code to call PowerManager.removeThermalStatusListener and AThermal_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);
   }
}

Let's abstract these functionalities by moving them to ADPFManager.java so that we can easily reuse them across other projects.

In your game's activity class, create and hold an instance of ADPFManager and hook up the add/remove thermal listener with their corresponding activity lifecycle method.

// 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();
}

Register your C++ class' native methods in JNI_OnLoad

On API Level 30 onwards, we can use the NDK Thermal API AThermal_* thus you can map the Java listener to call the same C++ methods. For Java methods to call into C++ code, you'll need to register the C++ methods in JNI_OnLoad. You can check out more JNI Tips to learn more about it.

// 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;
}

Hook up your native listeners to your game

Our C++ game will need to know when the corresponding thermal status has changed, so let's create the corresponding adpf_manager class in C++.

In the cpp folder of your app source ($ROOT/app/src/main/cpp) create a pair of adpf_manager.h and adpf_manager.cpp files

// 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);

Define the C functions in the cpp file outside of the ADPFManager class.

// 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);
  }
}

Initialize the PowerManager and functions required to retrieve thermal headroom

On API Level 30 onwards, we can use the NDK Thermal API AThermal_* thus on initialization, call AThermal_acquireManager and keep it for future use. On API Level 29, we will need to find the required Java references and retain them.

// 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;
}

Make sure that the initialization method will be called

In our sample, the initialization method is called from SetApplication and SetApplication is called from android_main. This setup is specific to our framework, so if you're integrating into your game, you'll need to find the right place to call the Initialize method

// 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();
}

Monitor thermal headroom regularly

It is often better to prevent the thermal status from being raised to a higher level as it is difficult to lower it without pausing the workload entirely. Even after completely shutting down, the device will take some time to dissipate heat and cool down. We can watch our thermal headroom regularly and adjust our workload to keep the headroom in check and prevent the thermal status from being raised.

In our ADPFManager, let's expose methods to check for the thermal headroom.

// 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_;
}

And finally, we will need to expose methods to set the thermal status and its listener. We will get the value of the thermal status from NDK's thermal API or the Java SDK calling into our native code.

// 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;
}

Include your adpf_manager.cpp to the compilation unit in CMakeLists.txt

Remember to add your newly created adpf_manager.cpp into your compilation unit.

We're done with the reusable ADPFManager java and cpp classes now, so you can grab these files and reuse it in other projects rather than re-writing the glue code again.

// 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

Define in-game parameters to change when thermal state deteriorated

This part onwards will be game specific. In our example, we will reduce the physics steps and the number of boxes whenever the thermal status is raised.

We will also monitor the thermal headroom but we will not do anything other than displaying the value on the HUD. In your game, you can react to the value by adjusting the amount of post-processing done by the graphic card, reducing the level of detail, etc.

// 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,
};

Create a thermal state changed listener function in your game

Now, we will need to create a cpp thermalListener for ADPFManager to call whenever it detects the device thermal level has changed. Create this function in your game to listen to the state changed values. We're keeping track of the last_state so that we can know whether the thermal level is going up or down.

// 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);

When the thermal state changed in your game, adapt accordingly

Each game will have different needs and priorities, what is very important for one game may not be essential for another so you will need to decide yourself how to optimize to prevent further heating.

In our sample, we are reducing the number of objects on screen and reducing the physics fidelity. This will ease both the CPU & GPU workload and hopefully bring the thermal down a bit. Be aware that it is usually difficult to bring the thermal down a lot unless the player takes a break and leaves the device to cool, this is the reason we monitor the thermal headroom closely and prevent the device from reaching thermal throttled state.

// 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;
  }
}

Also remember that peak performance delivered by CPU & GPU chips are usually inefficient, meaning the chips usually deliver maximum performance at a lot higher energy consumption and dissipate a large amount of heat. Contrast that with sustained performance, which is the most optimal performance per unit of energy consumed and heat dissipated. You can read more about this in Performance Management of Android Open Source Project.

Build and run your project, the current Thermal State and Thermal Headroom will be displayed and if the thermal state deteriorates, the physics steps and number of objects will be reduced.

4bdcfe567fc603c0.png

If anything goes wrong, you can compare your work with the commit of the repo titled [codelab] step: integrated thermal-api.

4. Integrate Game Mode API

Game Mode API allows you to optimize your game for the best performance or longest battery life according to the player's selection. It is available on selected Android 12 devices and all Android 13+ devices.

Update Android Manifest

Set appCategory

To use Game Mode API, your application category must be a game, let's indicate that in your <application> tag

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

For Android 13 and later

It is recommended that you target Android 13 users following the next 2 sub-steps:

Add game_mode_config <meta-data> and the corresponding xml file

// 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" />

If you are targeting Android 12 devices

Add each gamemode <meta-data> directly in 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"/>

Implement GameModeManager.java to abstract the GameMode feature

As Game Mode API does not have cpp interface yet, we will need to use the Java interface and provide JNI interfaces. Let's abstract it in GameModeManager.java so we can reuse the functionality in other projects.

// 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);
}

Adapt your Activity to initialize GameModeManager and retrieve the GameMode in onResume

Hook it up to the Activity lifecycle. Whenever the Game Mode is changed, your Activity will be restarted so we can capture the value during 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();
}

Implement GameModeManager class to store the gameMode selected by user for in-game retrieval

Let's create a cpp wrapper and store the Game Mode value in cpp for easy retrieval.

// 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;
};

Implement retrieveGameMode in your native code to pass the GameMode value to your game

This is the simplest and most efficient way, upon starting, retrieve the Game Mode value and pass it to your cpp variable for easy access. We can rely on the cached value without needing to make JNI call everytime.

// 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);
}

}

Include your game_mode_manager.cpp to the compilation unit in CMakeLists.txt

Remember to add your newly created game_mode_manager.cpp into your compilation unit.

We're done with the reusable GameModeManager java and cpp classes now, so you can grab these files and reuse it in other projects rather than re-writing the glue code again.

// 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

Adapt your game according to the user selected GameMode

Once you retrieve the Game Mode, you have to differentiate your game according to the value selected by the user. The most significant difference (and most polarizing values) lies between the PERFORMANCE mode and BATTERY mode. In PERFORMANCE mode, users typically want to immerse in the game and get the best experience without worrying about battery life, thus you can deliver the best fidelity as long as the frame rate is stable. In BATTERY mode, users would want to play your game for a longer time and they will be able to accept lower settings. Make sure that the frame rate is stable at all times because unstable frame rate gives the worst experience for the players.

// 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();

In our sample, we're rendering at full resolution and 60 FPS in PERFORMANCE mode. Since this is quite a simple example, most devices will be able to run at full FPS smoothly so we do not apply further checks to keep things simple. In BATTERY mode, we are capping the rendering at 30 FPS and a quarter resolution. You will need to find your own sweet spots to optimize. Always remember that game play experience and power saving are not limited to just FPS and resolution! For inspirations on how to optimize, checkout the success story by our developers.

// 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();

  ...
}

Remember to check your sdkVersions in your gradle build file

Game Mode API is available on all Android devices starting on Android 13. Selected Android 12 devices will also have this feature enabled.

// 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
        ...
    }

If anything goes wrong, you can compare your work with the commit of the repo titled [codelab] step: integrate game-mode-api.

496c76415d12cbed.png

5. Integrate Game State API

Game State API lets you tell the system the top-level state of your game, enabling you to tell whether the current content can be interrupted without interfering with game play that can't be paused. It also lets you indicate whether your game is currently doing heavy I/O such as loading assets from disks or network. Knowing all this data lets the system allocate the right resources at the right time for you (e.g: allocate the right amount of CPU & memory bandwidth when you're loading or prioritize network traffic for your multiplayer game when your player is in a crucial multiplayer session).

Define enums for easier mapping to GameState constants

Since Game State API does not have a cpp interface, let's start by copying over the values to 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,
};

Define SetGameState in your cpp code that will call the Java API through JNI

A cpp function is all we need to pass over all the information to the Java API.

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

Don't be overwhelmed, it is just one JNI call...

// 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);
  }
}

Call SetGameState whenever your game play state changes

Just call our cpp function during the appropriate time such as start loading, stop loading, or entering and exiting different states within your game.

// 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);
}

It is ok to pass UNKNOWN if you do not know the state at that time. In our case, we are unloading the scene and do not know where the user is heading next, but soon the next scene will load and we can call another SetGameState with the new state known.

// 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);
}

Easy right? Once you've integrated Game State API, the system will be aware of the states in your application and start to optimize the resources to achieve better performance & efficiency for your player.

Stay tuned for the next update for the Game State API where we will explain how you can use label and quality to further optimize your game!

6. Integrate Performance Hint API

Performance Hint API allows you to send performance hint to the system by creating Sessions for each thread group responsible for a specific task, set the initial target work duration and then on each frame report the actual work duration and update the expected work duration for the next frame.

For example, if you have a group of threads that is responsible for enemy AI, physics calculation, and render thread. All of these sub-tasks have to be completed every frame and an overrun in one of them will cause your frame to be delayed, missing the target FPS. You can create a PerformanceHint Session for this group of threads and set the targetWorkDuration to your desired target FPS. As you reportActualWorkDuration after the work done on each frame, the system can analyze this trend and adjust CPU resources accordingly to make sure that you can achieve your desired target every frame. This results in improved frame stability and more efficient power consumption for your game.

ADPFManager InitializePerformanceHintManager

We will need to create the hint session for the threads, there is C++ API but it is only available for API Level 33 onwards. For API Level 31 & 32, we will need to use the Java API, let's cache a few JNI methods we can use later.

// 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

}

Call it in ADPFManager::SetApplication

Remember to call the initialize function we've defined from the 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);

  ...
}

Define ADPFManager::BeginPerfHintSession & ADPFManager::EndPerfHintSession

Define the cpp methods to actually invoke the APIs through JNI, accepting all our required parameters.

// 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

  }
}

Call them at the start and end of each frame

On each frame, we will need to record the start time at the beginning of the frame, and report the actual time at the end of the frame. We will call both reportActualWorkDuration and updateTargetWorkDuration at the end of the frame. In our simple sample, our workload does not change between frames we will updateTargetWorkDuration with the consistent target value.

// 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. Congratulations

Congratulations, you have successfully integrated Adaptability features to a game.

Stay tuned as we will add more features from the Adaptability framework to Android.