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 przez wdrożenie wstępnego obracania.

Dzięki Vulkanowi możesz podać znacznie więcej informacji o stanie renderowania niż w przypadku OpenGL. W interfejsie Vulkan należy bezpośrednio wdrożyć elementy obsługiwane przez sterownik w OpenGL, takie jak orientacja urządzenia i jej związek z orientacją platformy. 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 obracaniem obrazu wyjściowego.
  3. Sama aplikacja może obsłużyć obrót powierzchni, renderując obrócony obraz na powierzchni renderowanej zgodnie z bieżącą orientacją wyświetlacza.

Którą z tych metod wykorzystasz?

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 Twoja aplikacja jest ograniczona CPU, staje się to problem z zasilaniem ze względu na zwiększone wykorzystanie GPU przez komponent Android Compositor, który zwykle działa ze zwiększoną 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 dostępnych 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.

  • Wywiera większy nacisk na pamięć wierzchołków/tekstur GPU, ponieważ kompozytor musi odczytać cały bufor ramek, 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 obsługę obrotów powierzchni przy jak najmniejszym narzutie, tak jak w poprzednim przypadku, należy wdrożyć 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. To zatrzymuje samodzielną rotację aplikacji Android Compositor.

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ż orientacja identyfikacyjna urządzenia. Na przykład aplikacja tylko w orientacji poziomej na telefonie z tożsamością pionową lub aplikacja tylko w orientacji pionowej na tablecie z orientacją poziomą.

Modyfikuj plik 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 aplikacja ustawia orientację ekranu za pomocą atrybutu screenOrientation, nie musisz tego robić. Jeśli aplikacja ma stałą orientację, wystarczy skonfigurować zamianę tylko raz podczas uruchamiania/wznawiania aplikacji.

Rozdzielczość ekranu identyfikacyjnego i parametry kamery

Następnie sprawdź 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 zwróconego parametru currentTransform, aby zapisywać 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 of the app's window surface in the display's natural orientation.

Wykrywanie zmian 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 ze strony vkQueuePresentKHR(). 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 odpytywania

Na urządzeniach z systemem starszym niż Android 10 można przeprowadzać sondowanie bieżącego urządzenia co pollingInterval klatki, gdzie pollingInterval to szczegółowość określona 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 telefonie Pixel 4 z Androidem 10 odpytywanievkGetPhysicalDeviceSurfaceCapabilitiesKHR() trwało 0,120–0,250 s, a na telefonie 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 stopni, np. z poziomej na pionową i odwrotnie. Inne zmiany orientacji nie spowodują odtworzenia łańcucha zamiany. Na przykład zmiana z orientacji poziomej na odwrotną nie spowoduje jej aktywowania, więc kompozytor Androida będzie musiał wykonać odwrócenie 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. Na przykład:

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 trzeba ponownie tworzyć, ponieważ są one 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 za pomocą swapchain

W poprzedniej sekcji wspominaliśmy, że trzeba ponownie utworzyć łańcuch wymiany. W tym celu musisz najpierw uzyskać nowe właściwości powierzchni renderującej:

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, korzystając z pola 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 zapisanym podczas uruchamiania aplikacji. Pole preTransform zostanie wypełnione zmienną pretransformFlag (ustawioną na pole currentTransform obiektu surfaceCapabilities). W polu oldSwapchain ustawisz też wartość wymiany, która zostanie zniszczona.

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 opcji dynamicznego 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ść informacji, 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 klatki obróczonej o 90 stopni
    • dFdx musi być zmapowany na dFdy.
    • Parametr dFdy musi być zmapowany na parametr -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 prerotacji. Najważniejsze wnioski z tego artykułu:

  • Upewnij się, że podczas tworzenia lub odtwarzania swapchain flaga jest ustawiona tak, aby była zgodna z flagą zwracaną przez system operacyjny Android. 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życ w sposób odpowiedni dla aplikacji.

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