Z tego artykułu dowiesz się, jak skutecznie obsługiwać obracanie urządzenia w aplikacji Vulkan, wdrażając wstępne obracanie.
Vulkan pozwala określić znacznie więcej informacji o stanie renderowania niż 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 uzgadniać powierzchnię renderowania urządzenia z jego orientacją na 3 sposoby:
- System operacyjny Android może używać procesora wyświetlacza (DPU) urządzenia, który może efektywnie obsługiwać obracanie powierzchni na poziomie sprzętowym. Dostępne tylko na obsługiwanych urządzeniach.
- System operacyjny Android może obsługiwać obracanie powierzchni przez dodanie przebiegu kompozytora. Będzie to miało wpływ na wydajność w zależności od tego, jak kompozytor musi poradzić sobie z obracaniem obrazu wyjściowego.
- Sama aplikacja może obsługiwać obrót powierzchni, renderując obrócony obraz na powierzchni renderowania, która odpowiada bieżącej orientacji wyświetlacza.
Którą z tych metod wybierzesz?
Obecnie aplikacja nie może stwierdzić, czy obrót powierzchni obsługiwany poza aplikacją będzie bezpłatny. Nawet jeśli masz procesor DPU, który się tym zajmuje, prawdopodobnie i tak odczujesz spadek wydajności. 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 aplikacja jest ograniczona przez GPU, Android Compositor może również wywłaszczyć pracę GPU aplikacji, co spowoduje dodatkowy spadek wydajności.
Podczas uruchamiania gier na Pixelu 4XL zauważyliśmy, że SurfaceFlinger (zadanie o wyższym priorytecie, które steruje kompozytorem Androida):
Regularnie wywłaszcza pracę aplikacji, powodując opóźnienia czasu renderowania klatek o 1–3 ms.
Zwiększa obciążenie pamięci wierzchołków/tekstur GPU, ponieważ kompozytor musi odczytać cały bufor ramki, aby wykonać kompozycję.
Prawidłowa obsługa orientacji niemal całkowicie zatrzymuje wywłaszczanie GPU przez SurfaceFlinger, a częstotliwość GPU spada o 40%, ponieważ zwiększona częstotliwość używana przez kompozytor Androida nie jest już potrzebna.
Aby zapewnić prawidłową obsługę obrotów 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 operacyjny 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. Zatrzymuje to obracanie wykonywane przez kompozytor Androida.
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 uważa 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 obiektu Activity Androida oraz wywoływaniu funkcji onDestroy() na istniejącej powierzchni okna, gdy następuje zmiana orientacji. W tym celu dodaj atrybuty orientation (aby obsługiwać interfejs API na poziomie <13) i screenSize do sekcji configChanges aktywności:
<activity android:name="android.app.NativeActivity"
android:configChanges="orientation|screenSize">
Jeśli aplikacja ma stałą orientację ekranu określoną za pomocą atrybutu screenOrientation, nie musisz tego robić. Jeśli aplikacja używa stałej orientacji, musi skonfigurować łańcuch wymiany tylko raz, podczas uruchamiania lub wznawiania.
Uzyskiwanie rozdzielczości ekranu i parametrów aparatu
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ą tożsamości urządzenia, dlatego łańcuch wymiany zawsze musi być ustawiony na tę wartość. Najbardziej niezawodnym sposobem na uzyskanie tych danych jest wywołanie metody vkGetPhysicalDeviceSurfaceCapabilitiesKHR() podczas uruchamiania aplikacji i zapisanie zwróconego zakresu. Zamień szerokość i wysokość na podstawie wartości currentTransform, która również jest 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órej używamy do przechowywania wspomnianej 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. 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 vkQueuePresentKHR(). Wynik tej kontroli 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 (Android 9 i starsze wersje)
W przypadku urządzeń z Androidem 10 lub starszym wymagana jest inna implementacja, ponieważ interfejs VK_SUBOPTIMAL_KHR nie jest obsługiwany.
Korzystanie z ankiet
Na urządzeniach z systemem starszym niż Android 10 możesz sprawdzać bieżące przekształcenie urządzenia co pollingInterval klatek, gdzie pollingInterval to szczegółowość określona przez programistę. Aby to zrobić, wywołaj vkGetPhysicalDeviceSurfaceCapabilitiesKHR(), a następnie porównaj zwrócone pole currentTransform z polem aktualnie przechowywanego przekształcenia powierzchni (w tym przykładowym kodzie przechowywanym 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 odpytywanie vkGetPhysicalDeviceSurfaceCapabilitiesKHR() trwało od 0,120 do 0,250 ms, a na Pixelu 1XL z Androidem 8 – od 0,110 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, ż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ą lub odwrotnie. Inne zmiany orientacji nie spowodują ponownego utworzenia łańcucha wymiany.
Na przykład zmiana z orientacji poziomej na odwróconą poziomą nie spowoduje wywołania tej metody, co wymaga od kompozytora Androida wykonania obrotu dla 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”. 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:
zniszczyć wszystkie istniejące instancje
FramebufferiImageView,ponownie utworzyć łańcuch wymiany, niszcząc stary (co omówimy w dalszej części);
Utwórz ponownie bufor ramki z nowymi obrazami DisplayImages łańcucha wymiany. Uwaga: obrazów załączników (np. obrazów głębi/szablonu) zwykle nie trzeba odtwarzać, ponieważ są one oparte na rozdzielczości tożsamości obrazów łańcucha wymiany przed obrotem.
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 pokazać, że zmiana orientacji została obsłużona.
Odtworzenie łańcucha wymiany
W poprzedniej sekcji wspomnieliśmy o konieczności ponownego utworzenia łańcucha 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;
Gdy struktura VkSurfaceCapabilities zostanie wypełniona nowymi informacjami, możesz sprawdzić, czy nastąpiła zmiana orientacji, sprawdzając pole currentTransform. Zapisz go na później w polu pretransformFlag, ponieważ będzie Ci potrzebny podczas wprowadzania zmian w macierzy MVP.
Aby to zrobić, określ w strukturze VkSwapchainCreateInfo 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 ustawisz też na łańcuch wymiany, który zostanie zniszczony.
Dostosowanie macierzy MVP
Ostatnią rzeczą, którą musisz zrobić, jest zastosowanie wstępnego przekształcenia przez zastosowanie macierzy obrotu do macierzy MVP. W praktyce oznacza to, że obrót jest stosowany w przestrzeni klipu, dzięki czemu wynikowy obraz jest obracany zgodnie z bieżącą orientacją urządzenia. Następnie możesz po prostu przekazać tę zaktualizowaną macierz MVP do cieniowania wierzchołków i używać jej jak zwykle, bez konieczności modyfikowania cieniowania.
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 – widoczny obszar i wycinek nie na pełnym ekranie
Jeśli aplikacja używa obszaru widoku lub obszaru przycinania, który nie jest pełnoekranowy, należy je zaktualizować zgodnie z orientacją urządzenia. Wymaga to włączenia dynamicznych opcji Viewport i Scissor 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 obliczanie zakresu widocznego obszaru podczas rejestrowania 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 jego szerokość i wysokość.
Te same obliczenia można też wykorzystać do ustawienia testu nożycowego. Podajemy je tutaj dla kompletności:
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);
Uwagi – pochodne fragmentu kodu cieniowania
Jeśli aplikacja używa obliczeń pochodnych, takich jak dFdx i dFdy, mogą być potrzebne dodatkowe przekształcenia, aby uwzględnić obrócony układ współrzędnych, ponieważ te obliczenia są wykonywane w przestrzeni pikseli. Wymaga to przekazania przez aplikację do fragmentu kodu cieniującego pewnych informacji o preTransform (np. liczby całkowitej reprezentującej bieżącą orientację urządzenia) i użycia ich do prawidłowego mapowania obliczeń pochodnych:
- W przypadku klatki obróconej o 90 stopni:
- dFdx musi być zmapowane na dFdy
- dFdy musi być zmapowane na -dFdx
- W przypadku klatki obróconej o 270 stopni:
- dFdx musi być zmapowane na -dFdy
- dFdy musi być zmapowane na dFdx
- W przypadku klatki obróconej 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 zaimplementować wstępne obracanie. Najważniejsze wnioski z tego artykułu:
- Upewnij się, że podczas tworzenia lub ponownego tworzenia łańcucha wymiany flaga wstępnego przekształcenia jest ustawiona tak, aby odpowiadała fladze zwracanej przez system operacyjny Android. Pozwoli to uniknąć narzutu kompozytora.
- Utrzymuj rozmiar łańcucha wymiany na poziomie rozdzielczości tożsamości powierzchni okna aplikacji w naturalnej orientacji wyświetlacza.
- Obróć macierz MVP w przestrzeni przycinania, aby uwzględnić orientację urządzenia, ponieważ rozdzielczość/zakres łańcucha wymiany nie są już aktualizowane wraz z orientacją wyświetlacza.
- Zaktualizuj prostokąty widocznego obszaru i wycinka zgodnie z potrzebami aplikacji.
Przykładowa aplikacja: minimalna wstępna rotacja na Androidzie