Obsługa orientacji urządzenia przy użyciu wstępnego obracania interfejsu Vulkan

Z tego artykułu dowiesz się, jak skutecznie obsługiwać obracanie urządzenia w aplikacji Vulkan, wdrażając wstępne obracanie.

W przypadku Vulkana możesz określić znacznie więcej informacji o stanie renderowania niż w przypadku OpenGL. W przypadku Vulkan musisz jawnie zaimplementować elementy, które są obsługiwane przez sterownik w OpenGL, takie jak orientacja urządzenia i jej związek z orientacją powierzchni renderowania. Android może dopasowywać powierzchnię renderowania urządzenia do jego orientacji na 3 sposoby:

  1. System operacyjny Android może korzystać z procesora wyświetlania (DPU) urządzenia, który wydajnie obsługuje obracanie powierzchni w sprzęcie. Dostępne tylko na obsługiwanych urządzeniach.
  2. System operacyjny Android może obsługiwać obracanie powierzchni, dodając etap kompozytora. Będzie to miało wpływ na wydajność w zależności od tego, jak kompozytor musi sobie radzić z obracaniem obrazu wyjściowego.
  3. Aplikacja może obsługiwać obracanie powierzchni, renderując obrócony obraz na powierzchni renderowania, która jest zgodna z bieżącą orientacją wyświetlacza.

Którą z tych metod należy zastosować?

Obecnie aplikacja nie może stwierdzić, czy obrót powierzchni obsługiwany poza nią będzie bezpłatny. Nawet jeśli masz jednostkę DPU, która się tym zajmie, prawdopodobnie poniesiesz wymierną karę za wydajność. Jeśli aplikacja jest ograniczona przez procesor, staje się to problemem związanym z zasilaniem ze względu na zwiększone wykorzystanie GPU przez kompozytor Androida, który zwykle działa ze zwiększoną częstotliwością. Jeśli Twoja aplikacja jest ograniczona przez GPU, Kompozytor Androida może również wyprzedzić pracę GPU aplikacji, co spowoduje dodatkową utratę wydajności.

Podczas uruchamiania gier na Pixelu 4XL zauważyliśmy, że SurfaceFlinger (zadanie o wyższym priorytecie, które steruje kompozytorem Androida):

  • regularnie wyprzedza pracę aplikacji, powodując wzrost czasu klatki o 1–3 ms,

  • Zwiększa obciążenie pamięci wierzchołków/tekstur procesora graficznego, ponieważ menedżer kompozycji musi odczytać cały bufor ramki, aby wykonać swoją pracę.

Prawidłowe określanie orientacji niemal całkowicie zapobiega wywłaszczaniu GPU przez SurfaceFlinger, a częstotliwość GPU spada o 40%, ponieważ nie jest już potrzebna zwiększona częstotliwość używana przez kompozytor Androida.

Aby zapewnić prawidłowe działanie rotacji powierzchni przy jak najmniejszym obciążeniu, jak w poprzednim przypadku, należy wdrożyć metodę 3. Jest to tzw. wstępna rotacja. Informuje to system Android, że Twoja aplikacja obsługuje obracanie powierzchni. Możesz to zrobić, przekazując flagi przekształcenia powierzchni, które określają orientację podczas tworzenia łańcucha wymiany. To uniemożliwia kompozytorowi Androida samodzielne obracanie obrazu.

Wiedza o tym, jak ustawić flagę przekształcenia powierzchni, jest ważna w przypadku każdej aplikacji Vulkan. Aplikacje zwykle obsługują wiele orientacji lub jedną orientację, w której powierzchnia renderowania ma inną orientację niż ta, którą urządzenie uznaje za swoją orientację tożsamości. Na przykład aplikacja działająca tylko w orientacji poziomej na telefonie z orientacją pionową lub aplikacja działająca tylko w orientacji pionowej na tablecie z orientacją poziomą.

Modyfikowanie pliku AndroidManifest.xml

Aby obsłużyć obracanie urządzenia w aplikacji, zacznij od zmiany pliku AndroidManifest.xml aplikacji, aby poinformować Androida, że aplikacja będzie obsługiwać zmiany orientacji i rozmiaru ekranu. Zapobiega to niszczeniu i ponownemu tworzeniu przez Androida obiektu Activity i wywoływaniu funkcji onDestroy() na istniejącej powierzchni okna po zmianie orientacji. W tym celu dodaj atrybuty orientation (aby obsługiwać poziom API <13) i screenSize do sekcji configChanges aktywności:

<activity android:name="android.app.NativeActivity"
          android:configChanges="orientation|screenSize">

Jeśli aplikacja ustala orientację ekranu za pomocą atrybutu screenOrientation, nie musisz tego robić. Jeśli aplikacja używa stałej orientacji, łańcuch wymiany będzie trzeba skonfigurować tylko raz, podczas uruchamiania lub wznawiania aplikacji.

Pobieranie rozdzielczości ekranu urządzenia i parametrów kamery

Następnie wykryj rozdzielczość ekranu urządzenia powiązaną z wartością VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. Ta rozdzielczość jest powiązana z orientacją urządzenia i dlatego zawsze musi być ustawiona w łańcuchu wymiany. Najbardziej niezawodnym sposobem jest wywołanie funkcji vkGetPhysicalDeviceSurfaceCapabilitiesKHR() przy uruchamianiu aplikacji i zapisanie zwróconego zakresu. Zamień szerokość i wysokość na podstawie wartości currentTransform, która jest też zwracana, aby mieć pewność, że zapisujesz rozdzielczość ekranu tożsamości:

VkSurfaceCapabilitiesKHR capabilities;
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);

uint32_t width = capabilities.currentExtent.width;
uint32_t height = capabilities.currentExtent.height;
if (capabilities.currentTransform & VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR ||
    capabilities.currentTransform & VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR) {
  // Swap to get identity width and height
  capabilities.currentExtent.height = width;
  capabilities.currentExtent.width = height;
}

displaySizeIdentity = capabilities.currentExtent;

displaySizeIdentity to VkExtent2D struktura, w której przechowujemy tożsamość rozdzielczości powierzchni okna aplikacji w naturalnej orientacji wyświetlacza.

Wykrywanie zmian orientacji urządzenia (Android 10 lub nowszy)

Najbardziej niezawodnym sposobem wykrywania zmiany orientacji w aplikacji jest sprawdzenie, czy funkcja vkQueuePresentKHR() zwraca wartość VK_SUBOPTIMAL_KHR. Na przykład:

auto res = vkQueuePresentKHR(queue_, &present_info);
if (res == VK_SUBOPTIMAL_KHR){
  orientationChanged = true;
}

Uwaga: to rozwiązanie działa tylko na urządzeniach z Androidem 10 lub nowszym. W tych wersjach Androida funkcja vkQueuePresentKHR() zwraca wartość VK_SUBOPTIMAL_KHR. Wynik tego sprawdzania przechowujemy w orientationChanged, czyli boolean, do którego można uzyskać dostęp z głównej pętli renderowania aplikacji.

Wykrywanie zmian orientacji urządzenia (wersje Androida starsze niż 10)

W przypadku urządzeń z Androidem 10 lub starszym wymagana jest inna implementacja, ponieważ VK_SUBOPTIMAL_KHR nie jest obsługiwane.

Korzystanie z ankiet

Na urządzeniach z Androidem w wersji starszej niż 10 możesz odpytywać o bieżącą transformację urządzenia co pollingInterval klatek, gdzie pollingInterval to poziom szczegółowości określony przez programistę. Aby to zrobić, wywołaj vkGetPhysicalDeviceSurfaceCapabilitiesKHR(), a następnie porównaj zwrócone pole currentTransform z polem aktualnie przechowywanej transformacji powierzchni (w tym przykładzie kodu przechowywanej w pretransformFlag).

currFrameCount++;
if (currFrameCount >= pollInterval){
  VkSurfaceCapabilitiesKHR capabilities;
  vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);

  if (pretransformFlag != capabilities.currentTransform) {
    window_resized = true;
  }
  currFrameCount = 0;
}

Na Pixelu 4 z Androidem 10 odczytvkGetPhysicalDeviceSurfaceCapabilitiesKHR() trwał od 0,120 ms do 0,250 ms, a na Pixelu 1XL z Androidem 8 – od 0,110 ms do 0,350 ms.

Korzystanie z wywołań zwrotnych

Drugą opcją w przypadku urządzeń z Androidem w wersji starszej niż 10 jest zarejestrowanie wywołania zwrotnego onNativeWindowResized(), które wywołuje funkcję ustawiającą flagę orientationChanged, sygnalizującą aplikacji zmianę orientacji:

void android_main(struct android_app *app) {
  ...
  app->activity->callbacks->onNativeWindowResized = ResizeCallback;
}

gdzie ResizeCallback jest zdefiniowany w ten sposób:

void ResizeCallback(ANativeActivity *activity, ANativeWindow *window){
  orientationChanged = true;
}

Problem z tym rozwiązaniem polega na tym, że funkcja onNativeWindowResized() jest wywoływana tylko w przypadku zmian orientacji o 90 stopni, np. z poziomej na pionową lub odwrotnie. Inne zmiany orientacji nie spowodują ponownego utworzenia łańcucha wymiany. Na przykład zmiana z orientacji poziomej na poziomą odwróconą nie spowoduje tego zdarzenia, co oznacza, że kompozytor Androida będzie musiał odwrócić obraz w Twojej aplikacji.

Obsługa zmiany orientacji

Aby obsłużyć zmianę orientacji, wywołaj procedurę zmiany orientacji na początku głównej pętli renderowania, gdy zmienna orientationChanged ma wartość true. Na przykład:

bool VulkanDrawFrame() {
 if (orientationChanged) {
   OnOrientationChange();
}

Wszystkie czynności niezbędne do ponownego utworzenia łańcucha wymiany wykonujesz w funkcji OnOrientationChange(). Oznacza to, że:

  1. Zniszcz wszystkie istniejące instancje FramebufferImageView.

  2. ponownie utworzyć łańcuch wymiany, niszcząc stary łańcuch (o czym będzie mowa w dalszej części);

  3. Utwórz ponownie bufory ramki z nowymi obrazami wyświetlania łańcucha wymiany. Uwaga: obrazów załączników (np. obrazów głębi lub szablonów) zwykle nie trzeba odtwarzać, ponieważ są one oparte na rozdzielczości tożsamości wstępnie obróconych obrazów łańcucha wymiany.

void OnOrientationChange() {
 vkDeviceWaitIdle(getDevice());

 for (int i = 0; i < getSwapchainLength(); ++i) {
   vkDestroyImageView(getDevice(), displayViews_[i], nullptr);
   vkDestroyFramebuffer(getDevice(), framebuffers_[i], nullptr);
 }

 createSwapChain(getSwapchain());
 createFrameBuffers(render_pass, depthBuffer.image_view);
 orientationChanged = false;
}

Na końcu funkcji resetujesz flagę orientationChanged na wartość false, aby wskazać, że zmiana orientacji została obsłużona.

Ponowne tworzenie łańcucha wymiany

W poprzedniej sekcji wspomnieliśmy o konieczności ponownego utworzenia łańcucha wymiany. Pierwsze kroki to uzyskanie nowych cech powierzchni renderowania:

void createSwapChain(VkSwapchainKHR oldSwapchain) {
   VkSurfaceCapabilitiesKHR capabilities;
   vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);
   pretransformFlag = capabilities.currentTransform;

Gdy struktura VkSurfaceCapabilities zostanie wypełniona nowymi informacjami, możesz sprawdzić, czy nastąpiła zmiana orientacji, sprawdzając pole currentTransform. Zapisz go w pretransformFlag później, ponieważ będzie Ci potrzebny do wprowadzania zmian w macierzy MVP.

Aby to zrobić, podaj te atrybuty w strukturze VkSwapchainCreateInfo:

VkSwapchainCreateInfoKHR swapchainCreateInfo{
  ...
  .sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR,
  .imageExtent = displaySizeIdentity,
  .preTransform = pretransformFlag,
  .oldSwapchain = oldSwapchain,
};

vkCreateSwapchainKHR(device_, &swapchainCreateInfo, nullptr, &swapchain_));

if (oldSwapchain != VK_NULL_HANDLE) {
  vkDestroySwapchainKHR(device_, oldSwapchain, nullptr);
}

Pole imageExtent zostanie wypełnione zakresem displaySizeIdentity, który został zapisany podczas uruchamiania aplikacji. Pole preTransform zostanie wypełnione zmienną pretransformFlag (która jest ustawiona na pole currentTransform obiektu surfaceCapabilities). Ustawiasz też pole oldSwapchain na łańcuch buforów, który zostanie zniszczony.

Korekta macierzy MVP

Ostatnią rzeczą, jaką musisz zrobić, jest zastosowanie przekształcenia wstępnego przez zastosowanie macierzy rotacji do macierzy MVP. W zasadzie polega to na zastosowaniu obrotu w przestrzeni klipu, tak aby wynikowy obraz był obrócony do bieżącej orientacji urządzenia. Następnie możesz po prostu przekazać tę zaktualizowaną macierz MVP do shadera wierzchołków i używać jej w normalny sposób bez konieczności modyfikowania shaderów.

glm::mat4 pre_rotate_mat = glm::mat4(1.0f);
glm::vec3 rotation_axis = glm::vec3(0.0f, 0.0f, 1.0f);

if (pretransformFlag & VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR) {
  pre_rotate_mat = glm::rotate(pre_rotate_mat, glm::radians(90.0f), rotation_axis);
}

else if (pretransformFlag & VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR) {
  pre_rotate_mat = glm::rotate(pre_rotate_mat, glm::radians(270.0f), rotation_axis);
}

else if (pretransformFlag & VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR) {
  pre_rotate_mat = glm::rotate(pre_rotate_mat, glm::radians(180.0f), rotation_axis);
}

MVP = pre_rotate_mat * MVP;

Uwaga – obszar widoku i obszar wycinania w trybie niepełnoekranowym

Jeśli aplikacja używa widocznego obszaru lub obszaru wycinania, który nie zajmuje całego ekranu, należy go zaktualizować zgodnie z orientacją urządzenia. Wymaga to włączenia opcji dynamicznego obszaru widoku i obszaru wycinania podczas tworzenia potoku Vulkan:

VkDynamicState dynamicStates[2] = {
  VK_DYNAMIC_STATE_VIEWPORT,
  VK_DYNAMIC_STATE_SCISSOR,
};

VkPipelineDynamicStateCreateInfo dynamicInfo = {
  .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
  .pNext = nullptr,
  .flags = 0,
  .dynamicStateCount = 2,
  .pDynamicStates = dynamicStates,
};

VkGraphicsPipelineCreateInfo pipelineCreateInfo = {
  .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,
  ...
  .pDynamicState = &dynamicInfo,
  ...
};

VkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineCreateInfo, nullptr, &mPipeline);

Rzeczywiste obliczenie zakresu widocznego obszaru podczas nagrywania bufora poleceń wygląda tak:

int x = 0, y = 0, w = 500, h = 400;

glm::vec4 viewportData;

switch (device->GetPretransformFlag()) {
  case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR:
    viewportData = {bufferWidth - h - y, x, h, w};
    break;
  case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR:
    viewportData = {bufferWidth - w - x, bufferHeight - h - y, w, h};
    break;
  case VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR:
    viewportData = {y, bufferHeight - w - x, h, w};
    break;
  default:
    viewportData = {x, y, w, h};
    break;
}

const VkViewport viewport = {
    .x = viewportData.x,
    .y = viewportData.y,
    .width = viewportData.z,
    .height = viewportData.w,
    .minDepth = 0.0F,
    .maxDepth = 1.0F,
};

vkCmdSetViewport(renderer->GetCurrentCommandBuffer(), 0, 1, &viewport);

Zmienne xy określają współrzędne lewego górnego rogu widocznego obszaru, a zmienne wh określają odpowiednio szerokość i wysokość widocznego obszaru. Tego samego obliczenia można użyć do ustawienia testu nożycowego. Podajemy je tutaj dla pełności informacji:

int x = 0, y = 0, w = 500, h = 400;
glm::vec4 scissorData;

switch (device->GetPretransformFlag()) {
  case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR:
    scissorData = {bufferWidth - h - y, x, h, w};
    break;
  case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR:
    scissorData = {bufferWidth - w - x, bufferHeight - h - y, w, h};
    break;
  case VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR:
    scissorData = {y, bufferHeight - w - x, h, w};
    break;
  default:
    scissorData = {x, y, w, h};
    break;
}

const VkRect2D scissor = {
    .offset =
        {
            .x = (int32_t)viewportData.x,
            .y = (int32_t)viewportData.y,
        },
    .extent =
        {
            .width = (uint32_t)viewportData.z,
            .height = (uint32_t)viewportData.w,
        },
};

vkCmdSetScissor(renderer->GetCurrentCommandBuffer(), 0, 1, &scissor);

Rozważania – pochodne shadera fragmentów

Jeśli aplikacja korzysta z obliczeń pochodnych, takich jak dFdxdFdy, może być konieczne przeprowadzenie dodatkowych przekształceń, aby uwzględnić obrócony układ współrzędnych, ponieważ obliczenia te są wykonywane w przestrzeni pikseli. Wymaga to przekazania do shadera fragmentów informacji o wstępnej transformacji (np. liczby całkowitej reprezentującej bieżącą orientację urządzenia) i użycia jej do prawidłowego mapowania obliczeń pochodnych:

  • W przypadku wstępnie obróconej klatki o 90 stopni:
    • dFdx musi być zmapowany na dFdy
    • dFdy musi być zmapowane na -dFdx
  • W przypadku wstępnie obróconej klatki o 270 stopni:
    • dFdx musi być zmapowane na -dFdy
    • dFdy musi być zmapowane na dFdx.
  • W przypadku wstępnie obróconej klatki o 180 stopni:
    • dFdx musi być zmapowane na -dFdx
    • dFdy musi być zmapowane na -dFdy

Podsumowanie

Aby aplikacja mogła w pełni wykorzystać możliwości interfejsu Vulkan na Androidzie, konieczne jest wdrożenie wstępnego obracania. Najważniejsze wnioski z tego artykułu:

  • Upewnij się, że podczas tworzenia lub ponownego tworzenia łańcucha wymiany flaga pretransform jest ustawiona tak, aby pasowała do flagi zwracanej przez system operacyjny Android. Pozwoli to uniknąć obciążenia kompozytora.
  • Utrzymuj stały rozmiar łańcucha wymiany, który odpowiada rozdzielczości okna aplikacji w naturalnej orientacji wyświetlacza.
  • Obróć macierz MVP w przestrzeni wycinania, aby uwzględnić orientację urządzenia, ponieważ rozdzielczość/zakres łańcucha wymiany nie jest już aktualizowany zgodnie z orientacją wyświetlacza.
  • W razie potrzeby zaktualizuj prostokąty widoku i obszaru wycinania.

Przykładowa aplikacja: minimalna rotacja wstępna na Androidzie