적응성 기능을 네이티브 게임에 통합

1. 소개

70748189a60ed450.png

적응성 기능을 게임에 통합해야 하는 이유는 무엇인가요?

적응성 API를 사용하면 애플리케이션 런타임 중에 기기 상태에 관한 피드백을 얻을 수 있으며 워크로드를 동적으로 조정하여 게임 성능을 최적화할 수 있습니다. 또한 시스템이 리소스를 최적으로 할당할 수 있도록 시스템에 워크로드에 관한 정보를 제공할 수 있습니다.

빌드할 항목

이 Codelab에서는 네이티브 Android 샘플 게임을 열고 실행한 후 적응성 기능을 게임과 통합합니다. 필요한 코드를 설정 및 추가한 후에는 이전 게임과 적응성이 통합된 버전 간에 인식되는 성능 차이를 테스트할 수 있습니다.

학습할 내용

  • Thermal API를 기존 게임에 통합하고 과열 방지를 위해 열 상태에 적응하는 방법
  • Game Mode API를 통합하고 선택 변경사항에 반응하는 방법
  • 게임이 실행되는 상태를 시스템이 알 수 있도록 Game State API를 통합하는 방법
  • 시스템이 스레딩 모델과 워크로드를 알 수 있도록 Performance Hint API를 통합하는 방법

필요한 항목

2. 설정

개발 환경 설정

이전에 Android 스튜디오에서 네이티브 프로젝트로 작업한 적이 없다면 Android NDK 및 CMake를 설치해야 할 수 있습니다. 이미 설치한 경우 프로젝트 설정을 진행합니다.

SDK, NDK, CMake가 설치되어 있는지 확인

Android 스튜디오를 시작합니다. 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 탭을 선택하여 NDKCMake가 설치되어 있는지 확인합니다.

참고: 적당히 최근 버전이기만 하면 정확한 버전은 중요하지 않지만 현재 NDK는 25.2.9519653, CMake는 3.24.0 버전입니다. 기본적으로 설치되는 NDK 버전은 시간이 지나며 후속 NDK 출시에 따라 변경됩니다. 특정 버전의 NDK를 설치해야 하는 경우 '특정 버전의 NDK 설치' 섹션에서 NDK 설치에 관한 Android 스튜디오 참조의 안내를 따르세요.

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인 저장소의 초기 커밋에서 시작하는지 확인합니다.

종속 항목 설정

샘플 프로젝트는 사용자 인터페이스에 Dear ImGui 라이브러리를 사용합니다. 또한 3D 물리 시뮬레이션에 Bullet Physics를 사용합니다. 이러한 라이브러리는 프로젝트 루트에 있는 third_party 디렉터리에 있다고 가정합니다. 위의 클론 명령어에 지정된 --recurse-submodules를 통해 각 라이브러리를 확인했습니다.

프로젝트 테스트

Android 스튜디오에서 디렉터리 루트의 프로젝트를 엽니다. 기기가 연결되어 있는지 확인한 다음 Build > Make ProjectRun > 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_*를 사용할 수 있으므로 동일한 C++ 메서드를 호출하도록 Java 리스너를 매핑할 수 있습니다. Java 메서드가 C++ 코드를 호출하려면 C++ 메서드를 JNI_OnLoad에 등록해야 합니다. 자세한 내용은 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.hadpf_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에서 호출됩니다. 이 설정은 Google 프레임워크에 한정되므로 게임에 통합한다면 Initialize 메서드를 호출할 적절한 위치를 찾아야 합니다.

// 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 javacpp 클래스가 완료되었으므로 글루 코드를 다시 작성하는 대신 이러한 파일을 가져와 다른 프로젝트에서 재사용할 수 있습니다.

// 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 워크로드가 완화되고 열이 다소 줄어들 수 있습니다. 플레이어가 휴식을 취하며 기기가 식도록 두지 않는 한 일반적으로 열을 많이 낮추기는 쉽지 않습니다. 이러한 이유로 Google에서는 열 헤드룸을 면밀히 모니터링하여 기기가 온도 제한 상태에 도달하지 않도록 합니다.

// 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 오픈소스 프로젝트의 성능 관리를 참고하세요.

프로젝트를 빌드하고 실행하면 현재 Thermal State와 Thermal Headroom이 표시됩니다. 열 상태가 악화되면 물리 단계와 객체 수가 줄어듭니다.

4bdcfe567fc603c0.png

문제가 있는 경우 [codelab] step: integrated thermal-api라는 저장소의 커밋과 작업을 비교하세요.

4. Game Mode API 통합

Game Mode API를 사용하면 플레이어의 선택에 따라 최상의 성능이나 가장 긴 배터리 수명에 맞게 게임을 최적화할 수 있습니다. 이 API는 일부 Android 12 기기 및 모든 Android 13 이상의 기기에서 사용할 수 있습니다.

Android 매니페스트 업데이트

appCategory 설정

Game Mode API를 사용하려면 애플리케이션 카테고리가 게임이어야 합니다. <application> 태그에 이를 표시해 보겠습니다.

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

Android 13 이상

다음 두 가지 하위 단계에 따라 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 기기를 타겟팅하는 경우

AndroidManifest에 각 게임 모드 <meta-data>를 직접 추가

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

GameModeManager를 초기화하고 onResume에서 GameMode를 가져오도록 활동 조정

활동 수명 주기에 연결합니다. 게임 모드가 변경될 때마다 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();
}

게임 내 검색을 위해 사용자가 선택한 gameMode를 저장하도록 GameModeManager 클래스 구현

쉽게 검색할 수 있도록 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;
};

GameMode 값을 게임에 전달하도록 네이티브 코드에 retrieveGameMode 구현

이 방법은 시작 시 게임 모드 값을 가져와 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 javacpp 클래스가 완료되었으므로 글루 코드를 다시 작성하는 대신 이러한 파일을 가져와 다른 프로젝트에서 재사용할 수 있습니다.

// 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에 따라 게임 조정

게임 모드를 가져온 후에는 사용자가 선택한 값에 따라 게임을 차별화해야 합니다. 가장 중요한 차이점(가장 양극화된 값)은 PERFORMANCE 모드와 BATTERY 모드 사이에 있습니다. PERFORMANCE 모드에서는 사용자가 일반적으로 배터리 수명에 관한 걱정 없이 게임에 몰입하고 최상의 환경을 경험하고자 합니다. 따라서 프레임 속도가 안정적이라면 최상의 충실도를 제공할 수 있습니다. BATTERY 모드에서는 사용자가 게임을 더 오래 플레이하고 싶어 하므로 더 낮은 설정을 허용할 수 있습니다. 프레임 속도가 불안정하면 플레이어에게 최악의 환경이 제공되므로 항상 프레임 속도가 안정적인지 확인하세요.

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

샘플의 경우 PERFORMANCE 모드에서 전체 해상도와 60FPS로 렌더링합니다. 이 샘플은 매우 간단한 예이므로 대부분의 기기가 전체 FPS로 원활하게 실행될 수 있으므로 단순성을 유지하기 위해 추가 검사를 적용하지 않습니다. BATTERY 모드에서는 렌더링이 30FPS 및 1/4 해상도로 제한됩니다. 직접 최적화할 포인트를 찾아야 합니다. 게임 플레이 환경과 절전은 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라는 저장소의 커밋과 작업을 비교하세요.

496c76415d12cbed.png

5. Game State API 통합

Game State API를 사용하면 시스템에 게임의 최상위 상태를 알려줄 수 있어 일시중지할 수 없는 게임 플레이를 방해하지 않고 현재 콘텐츠를 중단할 수 있는지 알 수 있습니다. 또한 디스크나 네트워크에서 애셋을 로드하는 등 게임에서 현재 I/O가 많은 작업을 실행하고 있는지 나타낼 수 있습니다. 이 데이터를 모두 알고 있으면 시스템이 적절한 리소스를 적절한 시점에 할당할 수 있습니다. 예를 들어 로드할 때 적절한 양의 CPU 및 메모리 대역폭을 할당하거나 플레이어가 중요한 멀티플레이어 세션에 있을 때 멀티플레이어 게임의 네트워크 트래픽의 우선순위를 지정합니다.

GameState 상수에 더 쉽게 매핑하도록 enum 정의

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 호출일 뿐입니다.

// 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을 모두 호출합니다. 간단한 샘플에서는 워크로드가 프레임 간에 변경되지 않으므로 일관된 타겟 값으로 TargetWorkDuration을 업데이트합니다.

// 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로 더 많은 기능을 추가할 예정이니 기대해 주세요.