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

Z tego artykułu dowiesz się, jak efektywnie obsługiwać obrót urządzenia w aplikacji Vulkan, wdrażając wstępną rotację.

Dzięki Vulkanowi możesz podać znacznie więcej informacji o stanie renderowania niż w przypadku OpenGL. W Vulkan musisz jawnie zaimplementować elementy obsługiwane przez sterownik w OpenGL, takie jak orientacja urządzenia i jej związek z orientacją powierzchni renderowania. Android może dostosować powierzchnię renderowania do orientacji urządzenia na 3 sposoby:

  1. System operacyjny Android może korzystać z jednostki przetwarzania wyświetlacza (DPU), która może efektywnie obsługiwać obracanie powierzchni na poziomie sprzętowym. Dostępne tylko na obsługiwanych urządzeniach.
  2. System operacyjny Android może obsługiwać obracanie powierzchni, dodając pass kompozytora. Może to mieć wpływ na wydajność w zależności od tego, jak kompozytor musi sobie poradzić z obróceniem obrazu wyjściowego.
  3. Aplikacja może obsługiwać obracanie powierzchni, renderując obracany obraz na powierzchni renderowania, która odpowiada bieżącej orientacji wyświetlacza.

Której z tych metod należy użyć?

Obecnie aplikacja nie może wiedzieć, czy obracanie powierzchni poza aplikacją będzie bezpłatne. Nawet jeśli masz dostęp do zespołu pomocy technicznej, który może Ci pomóc, prawdopodobnie będziesz musiał zapłacić karę za spadek skuteczności. Jeśli aplikacja jest związana z procesorem, problemem staje się zwiększone zużycie procesora przez kompozytor Androida, który zwykle działa z podwyższoną częstotliwością. Jeśli aplikacja jest związana z procesorem graficznym, kompozytor Androida może też wyprzedzić pracę procesora graficznego aplikacji, powodując dodatkową utratę wydajności.

Podczas uruchamiania tytułów w wersji produkcyjnej na urządzeniu Pixel 4XL zauważyliśmy, że SurfaceFlinger (czynność o wyższym priorytecie, która uruchamia kompozytor Androida):

  • Regularnie wyprzedza działanie aplikacji, co powoduje opóźnienia w czasie wyświetlania klatek o 1–3 ms.

  • Zwiększa obciążenie pamięci wierzchołków i tekstur GPU, ponieważ kompozytor musi odczytać cały bufor ramki, aby wykonać kompozycję.

Prawidłowe przetwarzanie orientacji powoduje, że SurfaceFlinger prawie całkowicie zatrzymuje wywłaszczanie GPU, a częstotliwość GPU spada o 40%, ponieważ częstotliwość podwyższona używana przez kompozytor Androida nie jest już potrzebna.

Aby zapewnić prawidłowe obracanie powierzchni z jak najmniejszym obciążeniem, jak w poprzednim przypadku, zastosuj metodę 3. Nazywamy to przed rotacją. Informuje system operacyjny Androida, że Twoja aplikacja obsługuje obracanie powierzchni. Aby to zrobić, możesz przekazać flagi przekształcania powierzchni, które określają orientację podczas tworzenia sekwencji wymiany. Zatrzymuje to kompozytor Androida przed samodzielnym przeprowadzeniem rotacji.

Umiejętność ustawiania flagi transformacji powierzchni jest ważna w przypadku każdej aplikacji Vulkan. Aplikacje zwykle obsługują wiele orientacji lub jedną orientację, w której powierzchnia renderowania jest w innej orientacji niż ta, którą urządzenie uważa za swoją tożsamość. Na przykład aplikacja w orientacji poziomej na telefonie z orientacją pionową lub aplikacja w orientacji pionowej na tablecie z orientacją poziomą.

Modyfikowanie pliku AndroidManifest.xml

Aby obsłużyć obrót 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 zniszczeniu i ponowemu utworzeniu obiektu Activity w Androidzie oraz wywołaniu funkcji onDestroy() na istniejącej powierzchni okna po zmianie orientacji. Aby to zrobić, dodaj atrybuty orientation (aby obsługiwać poziom interfejsu API <13) i screenSize do sekcji configChanges aktywności:

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

Jeśli orientacja ekranu w aplikacji jest ustalona za pomocą atrybutu screenOrientation, nie musisz tego robić. Jeśli aplikacja ma stałą orientację, swapchain trzeba skonfigurować tylko raz podczas uruchamiania lub wznawiania aplikacji.

Pobieranie rozdzielczości ekranu identyfikacyjnego i parametrów kamery

Następnie wykryj rozdzielczość ekranu urządzenia powiązaną z wartością VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. Rozdzielczość jest powiązana z orientacją tożsamości urządzenia i dlatego zawsze musi być ustawiona w swapchain. Najbardziej niezawodnym sposobem jest wywołanie funkcji vkGetPhysicalDeviceSurfaceCapabilitiesKHR() podczas uruchamiania aplikacji i zapisanie zwracanego zakresu. Zamień szerokość i wysokość na podstawie wartości currentTransform, która jest również zwracana, aby mieć pewność, że przechowujesz 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 struktura VkExtent2D, która służy do przechowywania wspomnianej tożsamości. resolution to powierzchnia okna aplikacji w naturalnej orientacji wyświetlacza.

Wykrywanie zmian orientacji urządzenia (Android 10 i nowsze)

Najpewniejszym sposobem wykrywania zmiany orientacji w aplikacji jest sprawdzenie, czy funkcja vkQueuePresentKHR() zwraca wartość VK_SUBOPTIMAL_KHR. Może to obejmować np. te funkcje:

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. Te wersje Androida zwracająVK_SUBOPTIMAL_KHRvkQueuePresentKHR(). Wynik tego sprawdzenia jest przechowywany w orientationChanged, czyli boolean, który jest dostępny z głównej pętli renderowania aplikacji.

Wykrywanie zmian orientacji urządzenia (przed Androidem 10)

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

Korzystanie z ankiet

Na urządzeniach z wersją Android 10 i starszą możesz sprawdzać bieżącą transformację urządzenia co pollingInterval ramek, gdzie pollingInterval to poziom szczegółowości określony przez programistę. Aby to zrobić, wywołaj funkcję vkGetPhysicalDeviceSurfaceCapabilitiesKHR(), a potem porównaj zwrócone pole currentTransform z polem bieżącej przekształcenia powierzchni (w tym przykładzie kodu przechowywanego w pliku pretransformFlag).

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

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

Na urządzeniu Pixel 4 z Androidem 10 odpytywanievkGetPhysicalDeviceSurfaceCapabilitiesKHR() trwało 0,120–0,250 s, a na urządzeniu Pixel 1XL z Androidem 8 – 0,110–0,350 s.

Używanie wywołań zwrotnych

Drugą opcją na urządzeniach z Androidem w wersji starszej niż 10 jest zarejestrowanie wywołania zwrotnego onNativeWindowResized(), aby wywołać funkcję, która ustawia flagę orientationChanged, sygnalizując aplikacji, że nastąpiła zmiana orientacji:

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

Gdzie ResizeCallback jest zdefiniowany jako:

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°, np. z poziomej na pionową i odwrotnie. Inne zmiany orientacji nie spowodują odtworzenia łańcucha wymiany. Na przykład zmiana orientacji z poziomej na odwróconą poziomo nie spowoduje jej uruchomienia, ponieważ kompozytor Androida musi przeprowadzić odwrócenie w aplikacji.

Obsługa zmiany orientacji

Aby obsłużyć zmianę orientacji, wywołaj rutynę zmiany orientacji na początku głównej pętli renderowania, gdy zmienna orientationChanged ma wartość true. Może to obejmować np. te funkcje:

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

W ramach funkcji OnOrientationChange() musisz wykonać wszystkie czynności niezbędne do odtworzenia łańcucha wymiany. Oznacza to, że:

  1. zniszczenie wszystkich istniejących instancji Framebuffer i ImageView,

  2. Utwórz ponownie łańcuch wymiany, usuwając stary łańcuch wymiany (omówimy to w następnym punkcie),

  3. Utwórz ponownie Framebuffers za pomocą DisplayImages nowego swapchaina. Uwaga: obrazy załączników (np. obrazy głębi lub szablonów) zwykle nie wymagają ponownego tworzenia, ponieważ są oparte na identycznej rozdzielczości obrazów z łańcucha zamiany.

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 zresetuj flagę orientationChanged na false, aby pokazać, że zmiana orientacji została obsłużona.

Odtworzenie w ramach swapchain

W poprzedniej sekcji wspominaliśmy, że trzeba ponownie utworzyć łańcuch wymiany. Pierwszym krokiem jest uzyskanie nowych właściwości powierzchni renderowania:

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

Po wypełnieniu struktury VkSurfaceCapabilities nowymi informacjami możesz sprawdzić, czy nastąpiła zmiana orientacji, sprawdzając pole currentTransform. Zapisz go w polu pretransformFlag, ponieważ będzie Ci potrzebny, gdy wprowadzisz zmiany w macierz MVP.

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

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). Pole oldSwapchain jest też ustawione na swapchain, który zostanie zniszczony.

Korekta macierzy MVP

Ostatnią rzeczą, którą musisz zrobić, jest zastosowanie transformacji wstępnej przez zastosowanie macierzy obrotu do macierzy MVP. Polecenie to powoduje obrócenie obrazu w ramach klipu, tak aby obraz wynikowy był obrócony zgodnie z bieżącą orientacją urządzenia. Następnie możesz po prostu przekazać zaktualizowaną macierz MVP do shadera wierzchołka i używać jej jak zwykle 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 okna pełnoekranowego i nożyczki

Jeśli Twoja aplikacja używa widoku/regionu nożyczek, który nie zajmuje całego ekranu, musisz go zaktualizować zgodnie z orientacją urządzenia. Wymaga to włączenia dynamicznych opcji Viewport i Scissor podczas tworzenia potoku Vulkana:

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 widoku podczas nagrywania za pomocą 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 definiują współrzędne lewego górnego rogu widocznego obszaru, a z kolei wh definiują odpowiednio szerokość i wysokość widocznego obszaru. Tego samego obliczenia można użyć do przeprowadzenia testu nożyczek. Aby zachować pełność, podajemy je tutaj:

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

Uwaga – pochodne fragmentu shadera

Jeśli Twoja aplikacja korzysta z obliczeń pochodnych, takich jak dFdxdFdy, mogą być potrzebne dodatkowe transformacje, aby uwzględnić obrócony układ współrzędnych, ponieważ te obliczenia są wykonywane w przestrzeni pikseli. Wymaga to, aby aplikacja przekazała do fragment shadera pewną wskazówkę dotyczącą preTransform (np. liczbę całkowitą reprezentującą bieżącą orientację urządzenia), która posłuży do prawidłowego mapowania obliczeń pochodnej:

  • W przypadku obrócone o 90 stopni
    • dFdx musi być mapowany na dFdy.
    • dFdy musi być zmapowane na -dFdx.
  • W przypadku obrotu o 270 stopni:
    • dFdx musi być zmapowany na -dFdy.
    • dFdy musi być zmapowany na dFdx.
  • W przypadku obrot o 180 stopni:
    • dFdx musi być zmapowany na -dFdx
    • dFdy musi być zmapowany na -dFdy

Podsumowanie

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

  • Upewnij się, że podczas tworzenia lub ponownego tworzenia łańcucha wymiany flaga pretransformacji jest ustawiona tak, aby pasowała do flagi zwracanej przez system operacyjny Androida. Pozwoli to uniknąć obciążenia kompozytora.
  • Rozmiar swapchaina powinien być równy rozdzielczości identyfikatora powierzchni okna aplikacji w naturalnej orientacji wyświetlacza.
  • Obróć macierz MVP w przestrzeni klipu, aby uwzględnić orientację urządzenia, ponieważ rozdzielczość/zakres swapchain nie jest już aktualizowany zgodnie z orientacją wyświetlacza.
  • W razie potrzeby zaktualizuj obszar widoku i prostokąty nożyczek.

Przykładowa aplikacja: minimalna wersja na Androida przed rotacją