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

W tym artykule opisujemy, jak skutecznie obsługiwać rotację urządzeń w aplikacji Vulkan przez wdrożenie wstępnej rotacji.

Interfejs Vulkan pozwala podać znacznie więcej informacji o stanie renderowania niż w przypadku OpenGL. W interfejsie Vulkan musisz bezpośrednio implementować elementy obsługiwane przez sterownik w OpenGL, np. orientację urządzenia i związek z orientacją powierzchni renderowania. Android umożliwia uzgadnianie powierzchni renderowania urządzenia z orientacją jego orientacji na 3 sposoby:

  1. System operacyjny Android może korzystać z interfejsu DPU (Display Processing Unit – DPU) urządzenia, który potrafi sprawnie obsługiwać obrót powierzchni sprzętowych urządzeń. Dostępne tylko na obsługiwanych urządzeniach.
  2. System operacyjny Android może obsługiwać obrót powierzchni przez dodanie przekazywania kompozytora. Będzie to kosztować wydajność w zależności od tego, jak kompozytor ma postępować z rotacją obrazu wyjściowego.
  3. Aplikacja może obsługiwać obrót powierzchni, renderując na niej obrócony obraz zgodny z bieżącą orientacją wyświetlacza.

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

Obecnie aplikacja nie ma możliwości sprawdzenia, czy obrót powierzchni obsługiwany poza aplikacją będzie bezpłatny. Nawet jeśli firma zajmie się tym za Ciebie, prawdopodobnie naliczymy mierzalną karę za wyniki. Jeśli aplikacja jest ograniczona do procesora, staje się to problemem z wykorzystaniem mocy obliczeniowej spowodowanego większym zużyciem GPU przez kompozytor Androida, który zwykle działa ze zwiększoną częstotliwością. Jeśli Twoja aplikacja jest powiązana z GPU, Android Compositor może też zablokować jej pracę, co spowoduje dodatkową utratę wydajności.

Podczas przesyłania tytułów dostawy na Pixelu 4XL zauważyliśmy, że SurfaceFlinger (zadanie o wyższym priorytecie, które obsługuje Android Compositora):

  • Regularnie przerywa działanie aplikacji, co wydłuża czas wczytywania klatek o 1–3 ms.

  • Wywiera większą presję na pamięć wierzchołków/tekstur GPU, ponieważ kompozytor musi odczytać cały bufor ramki, aby wykonać kompozycję.

Obsługa orientacji niemal całkowicie zatrzymuje pomijanie GPU przez SurfaceFlinger, a częstotliwość GPU spada o 40%, ponieważ ulepszona częstotliwość używana przez Android Compositora nie jest już potrzebna.

Aby zapewnić prawidłową obsługę obrotów powierzchni z jak najmniejszym narzutem, jak pokazano w poprzednim przypadku, zastosuj metodę 3. Nazywamy to rotacją wstępną. Informuje to system operacyjny Android, że Twoja aplikacja obsługuje obrót powierzchni. Możesz to zrobić, przekazując flagi przekształcenia powierzchni, które określają orientację podczas tworzenia łańcucha wymiany. To zatrzymujekompozytor Android samodzielnie.

W przypadku każdej aplikacji Vulkan ważne jest, aby wiedzieć, jak ustawić flagę przekształcenia powierzchni. Aplikacje zwykle obsługują wiele orientacji lub jedną orientację, w której powierzchnia renderowania jest w innej orientacji niż urządzenie uznawane za orientację tożsamości. Może to być na przykład aplikacja w orientacji poziomej na telefonie obsługującym tożsamość pionową lub aplikacja tylko w orientacji pionowej na tablecie obsługującym tożsamość poziomą.

Modyfikacja pliku AndroidManifest.xml

Obsługa obrotu urządzenia w aplikacji zacznij od zmiany jej pliku AndroidManifest.xml, aby poinformować Androida, że będzie ona obsługiwać zmiany orientacji i rozmiaru ekranu. Dzięki temu Android nie zniszczy i odtworzy Androida Activity oraz nie wywoła 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 działania:

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

Jeśli aplikacja naprawia orientację ekranu za pomocą atrybutu screenOrientation, nie musisz tego robić. Poza tym jeśli aplikacja używa stałej orientacji, wystarczy skonfigurować łańcuch zamiany tylko raz podczas uruchamiania/wznawiania aplikacji.

Sprawdź rozdzielczość ekranu tożsamości i parametry aparatu

Następnie sprawdź rozdzielczość ekranu urządzenia powiązaną z wartością VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. Rozdzielczość ta jest powiązana z orientacją tożsamości urządzenia i dlatego zawsze należy ustawiać łańcuch zamiany. Najbardziej niezawodnym sposobem jest wywołanie funkcji vkGetPhysicalDeviceSurfaceCapabilitiesKHR() podczas uruchamiania aplikacji i zapisanie zwróconego zakresu. Zamień szerokość i wysokość na podstawie zwracanego parametru currentTransform, aby mieć pewność, że zachowujesz 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órej używamy do przechowywania tej rozdzielczości tożsamości powierzchni okna aplikacji w naturalnej orientacji wyświetlacza.

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

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. Te wersje Androida zwracają VK_SUBOPTIMAL_KHR z systemu vkQueuePresentKHR(). Wynik tej kontroli zapisujemy w usłudze orientationChanged, czyli boolean, do którego można uzyskać dostęp z głównej pętli renderowania aplikacji.

Wykrywanie zmian w orientacji urządzeń (starsze niż 10)

Na urządzeniach z Androidem 10 lub starszym wymagana jest inna implementacja, ponieważ usługa VK_SUBOPTIMAL_KHR nie jest obsługiwana.

Korzystanie z ankiet

Na urządzeniach z systemem starszym niż Android 10 możesz odpytywać bieżące urządzenie o przekształcenie co pollingInterval klatek, przy czym pollingInterval to szczegółowość określona przez programistę. W tym celu wywołasz funkcję vkGetPhysicalDeviceSurfaceCapabilitiesKHR(), a następnie porównasz zwrócone pole currentTransform z obecnie zapisanym przekształceniem powierzchni (w tym przykładzie kodu przechowywanego w pretransformFlag).

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

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

W przypadku Pixela 4 z Androidem 10 odpytywanie vkGetPhysicalDeviceSurfaceCapabilitiesKHR() trwało od 0,120 do 0,250 ms, a na Pixelu 1XL z Androidem 8 – od 110 do 350 ms.

Korzystanie z wywołań zwrotnych

Drugą opcją w przypadku urządzeń z Androidem 10 jest zarejestrowanie wywołania zwrotnego onNativeWindowResized() w celu wywołania funkcji, 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 w tym rozwiązaniu polega na tym, że onNativeWindowResized() jest wywoływany tylko przy zmianie orientacji ekranu o 90 stopni, np. z poziomej na pionową lub odwrotnie. Inne zmiany orientacji nie spowodują odtworzenia łańcucha wymiany. Na przykład zmiana z orientacji poziomej na wsteczną nie spowoduje jej wywołania, przez co kompozytor Androida musi wykonać odwrócenie aplikacji.

Dostosowanie do zmiany orientacji

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

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

Wykonujesz wszystkie działania niezbędne do odtworzenia łańcucha wymiany w funkcji OnOrientationChange(). Oznacza to, że:

  1. Zniszcz wszystkie istniejące instancje Framebuffer i ImageView.

  2. odtwórz łańcuch wymiany, niszcząc stary łańcuch zamiany (co zostanie omówione w następnej kolejności).

  3. Odtwórz bufory ramek za pomocą elementów DisplayImages w nowej usłudze wymiany. Uwaga: zwykle nie trzeba odtwarzać obrazów załączników (np. obrazów głębi lub szablonów), ponieważ zależą one od rozdzielczości tożsamości 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 do wartości false (fałsz), aby pokazać, że zmiana orientacji została wykonana.

Zamiana łańcuchów

W poprzedniej sekcji wspomnieliśmy o konieczności odtworzenia łańcucha wymiany. Najpierw trzeba pobrać nowe cechy powierzchni do renderowania:

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

Gdy struktura VkSurfaceCapabilities zawiera nowe informacje, możesz teraz sprawdzić w polu currentTransform, czy nastąpiła zmiana orientacji. Zapiszesz go na później w polu pretransformFlag, ponieważ będzie on potrzebny później podczas 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 zapisanego podczas uruchamiania aplikacji. Pole preTransform będzie zawierać zmienną pretransformFlag (ustawioną na pole currentTransform obiektu surfaceCapabilities). Ustawisz też wartość pola oldSwapchain na swapchain, który zostanie zniszczony.

Dostosowanie macierzy MVP

Ostatnią czynnością, jaką musisz zrobić, jest zastosowanie przekształcenia wstępnego przez zastosowanie macierzy rotacji do macierzy MVP. Polega to na obróceniu obrazu w miejscu klipu, tak aby obraz został obrócony do bieżącej orientacji urządzenia. Następnie możesz po prostu przekazać tę zaktualizowaną macierz MVP do programu cieniowania wierzchołków i używać jej jak zwykle bez konieczności modyfikowania cieniownikó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;

Rozważanie zakupu – widoczny obszar niepełnoekranowy lub nożycowy

Jeśli aplikacja korzysta z widocznego obszaru lub obszaru, który nie jest widoczny na pełnym ekranie, musisz go zaktualizować odpowiednio do orientacji urządzenia. Wymaga to włączenia opcji dynamicznego widocznego obszaru i nożyczek 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 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 x i y określają współrzędne lewego górnego rogu widocznego obszaru, a w i h – odpowiednio szerokość i wysokość. Te same obliczenia można użyć do skonfigurowania testu nożycowego. Dla pełnej kompletności podane są 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);

Rozważanie zakupu – pochodne elementów Shader

Jeśli Twoja aplikacja stosuje obliczenia pochodne, takie jak dFdx i dFdy, mogą być konieczne dodatkowe przekształcenia w celu uwzględnienia obróconego systemu współrzędnych, ponieważ obliczenia są wykonywane w przestrzeni pikseli. Wymaga to od aplikacji przekazania pewnych informacji o metodzie preTransform do cieniowania fragmentów (na przykład liczby całkowitej reprezentującej bieżącą orientację urządzenia) i wykorzystania tych danych do poprawnego zmapowania obliczeń pochodnych:

  • Klatka obrócona o 90 stopni
    • dFdx musi być zmapowane na dFdy.
    • dFdy musi być zmapowane na -dFdx.
  • Z wstępnie obróconą klatką o 270 stopniach
    • dFdx musi być zmapowane na -dFdy.
    • dFdy musi być zmapowane na dFdx.
  • W przypadku klatki z wstępnie obróconym 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, musisz zastosować wcześniejszą rotację. Najważniejsze wnioski z tego artykułu:

  • Sprawdź, czy podczas tworzenia lub odtwarzania łańcucha zamiany flaga wstępnego przekształcania jest ustawiona tak, aby pasowała do flagi zwróconej przez system operacyjny Android. Pozwoli to uniknąć obciążenia kompozytora.
  • Zadbaj o to, aby rozmiar łańcucha wymiany był taki sam jak rozdzielczość tożsamości okna aplikacji i naturalna orientacja wyświetlacza.
  • Obróć macierz MVP w miejscu klipu, aby uwzględnić orientację urządzenia, ponieważ rozdzielczość/zakres wymiany nie jest już aktualizowany wraz z orientacją wyświetlacza.
  • Zaktualizuj widoczny obszar i prostokąty nożyczek zgodnie z potrzebami aplikacji.

Przykładowa aplikacja: minimalna rotacja urządzeń z Androidem