Geräteausrichtung mit Vulkan-Vorrotation behandeln

In diesem Artikel wird beschrieben, wie Sie die Gerätedrehung in Ihrer Vulkan-Anwendung effizient verarbeiten, indem Sie eine Vordrehung implementieren.

Mit Vulkan können Sie viel mehr Informationen zum Rendering-Status angeben als mit OpenGL. Bei Vulkan müssen Sie Dinge explizit implementieren, die in OpenGL vom Treiber verarbeitet werden, z. B. die Geräteorientierung und ihre Beziehung zur Renderoberflächenorientierung. Android kann die Übereinstimmung der Darstellungsfläche des Geräts mit der Geräteausrichtung auf drei Arten verarbeiten:

  1. Das Android-Betriebssystem kann die Display Processing Unit (DPU) des Geräts verwenden, die die Bildschirmdrehung effizient in der Hardware verarbeiten kann. Nur auf unterstützten Geräten verfügbar.
  2. Das Android-Betriebssystem kann die Oberflächenrotation durch Hinzufügen eines Compose-Passes verarbeiten. Dies hat Leistungskosten, je nachdem, wie der Compositor mit der Drehung des Ausgabebilds umgehen muss.
  3. Die Anwendung selbst kann die Oberflächendrehung verarbeiten, indem ein gedrehtes Bild auf einer Rendering-Oberfläche gerendert wird, die der aktuellen Ausrichtung des Displays entspricht.

Welche dieser Methoden sollten Sie verwenden?

Derzeit gibt es keine Möglichkeit für eine Anwendung, zu erkennen, ob die Oberflächendrehung, die außerhalb der Anwendung erfolgt, kostenlos ist. Auch wenn eine DPU diese Aufgabe für Sie übernimmt, ist wahrscheinlich trotzdem mit einer messbaren Leistungseinbuße zu rechnen. Wenn Ihre Anwendung CPU-gebunden ist, wird dies zu einem Leistungsproblem, da die GPU-Auslastung durch den Android-Compositor erhöht wird, der normalerweise mit einer erhöhten Taktfrequenz ausgeführt wird. Wenn Ihre Anwendung GPU-gebunden ist, kann der Android-Compositor auch die GPU-Arbeit Ihrer Anwendung vorwegnehmen, was zu weiteren Leistungseinbußen führt.

Beim Ausführen von Titeln, die mit Pixel 4 XL ausgeliefert werden, haben wir festgestellt, dass SurfaceFlinger (die Aufgabe mit der höheren Priorität, die den Android-Compositor steuert):

  • Unterbricht regelmäßig die Arbeit der Anwendung, was zu Framezeiten von 1–3 ms führt, und

  • Erhöht den Druck auf den Vertex-/Texturspeicher der GPU, da der Compositor den gesamten Framebuffer lesen muss, um die Komposition auszuführen.

Durch die korrekte Ausrichtung wird die GPU-Voraktivierung durch SurfaceFlinger fast vollständig verhindert, während die GPU-Taktfrequenz um 40% sinkt, da die vom Android-Compositor verwendete erhöhte Taktfrequenz nicht mehr benötigt wird.

Damit Oberflächendrehungen wie im vorherigen Fall ordnungsgemäß und mit möglichst wenig Overhead verarbeitet werden, sollten Sie Methode 3 implementieren. Dies wird als Vorauswahl bezeichnet. Dadurch wird dem Android-Betriebssystem mitgeteilt, dass Ihre App die Bildschirmdrehung übernimmt. Dazu übergeben Sie beim Erstellen des Swapchains Flags für die Oberflächentransformation, die die Ausrichtung angeben. Dadurch wird verhindert, dass der Android-Compositor die Drehung selbst ausführt.

Für jede Vulkan-Anwendung ist es wichtig zu wissen, wie das Surface-Transform-Flag festgelegt wird. Anwendungen unterstützen in der Regel entweder mehrere Ausrichtungen oder eine einzelne Ausrichtung, bei der sich die Darstellungsfläche in einer anderen Ausrichtung befindet als die Identitätsausrichtung des Geräts. Beispielsweise eine nur im Querformat nutzbare App auf einem Smartphone mit Hochformat-Display oder eine nur im Hochformat nutzbare App auf einem Tablet mit Querformat-Display.

AndroidManifest.xml ändern

Wenn Sie die Gerätedrehung in Ihrer App unterstützen möchten, ändern Sie zuerst die AndroidManifest.xml-Datei der Anwendung, um Android mitzuteilen, dass Ihre App Änderungen an Ausrichtung und Bildschirmgröße verarbeiten wird. So wird verhindert, dass Android die Android-Activity zerstört und neu erstellt und die Funktion onDestroy() auf der vorhandenen Fensteroberfläche aufruft, wenn sich die Ausrichtung ändert. Dazu fügen Sie die Attribute orientation (für API-Ebene < 13) und screenSize dem Abschnitt configChanges der Aktivität hinzu:

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

Wenn die Bildschirmausrichtung in Ihrer Anwendung mit dem Attribut screenOrientation festgelegt wird, ist das nicht erforderlich. Wenn Ihre Anwendung eine feste Ausrichtung verwendet, muss die Swapchain beim Starten oder Fortsetzen der Anwendung nur einmal eingerichtet werden.

Auflösung des Identitätsbildschirms und Kameraparameter abrufen

Ermitteln Sie als Nächstes die Bildschirmauflösung des Geräts, die mit dem Wert VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR verknüpft ist. Diese Auflösung ist mit der Identitätsausrichtung des Geräts verknüpft und muss daher immer für den Swapchain festgelegt werden. Am zuverlässigsten ist es, beim Starten der Anwendung vkGetPhysicalDeviceSurfaceCapabilitiesKHR() aufzurufen und die zurückgegebene Ausdehnung zu speichern. Tausche Breite und Höhe entsprechend der zurückgegebenen currentTransform aus, damit die Bildschirmauflösung der Identität gespeichert wird:

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“ ist eine VkExtent2D-Struktur, mit der wir die Identitätsauflösung der Fensteroberfläche der App in der natürlichen Ausrichtung des Displays speichern.

Änderungen der Geräteausrichtung erkennen (Android 10 und höher)

Die zuverlässigste Methode, eine Änderung der Ausrichtung in Ihrer Anwendung zu erkennen, besteht darin, zu prüfen, ob die Funktion vkQueuePresentKHR() den Wert VK_SUBOPTIMAL_KHR zurückgibt. Beispiel:

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

Hinweis:Diese Lösung funktioniert nur auf Geräten mit Android 10 oder höher. Bei diesen Android-Versionen wird VK_SUBOPTIMAL_KHR von vkQueuePresentKHR() zurückgegeben. Das Ergebnis dieser Prüfung wird in orientationChanged gespeichert, einer boolean, auf die über die Haupt-Renderingschleife der Anwendung zugegriffen werden kann.

Änderungen der Geräteausrichtung erkennen (vor Android 10)

Für Geräte mit Android 10 oder niedriger ist eine andere Implementierung erforderlich, da VK_SUBOPTIMAL_KHR nicht unterstützt wird.

Abfragen verwenden

Auf Geräten mit einer älteren Android-Version können Sie die aktuelle Gerätetransformation alle pollingInterval Frames abfragen. pollingInterval ist eine vom Programmierer festgelegte Detailebene. Rufen Sie dazu vkGetPhysicalDeviceSurfaceCapabilitiesKHR() auf und vergleichen Sie das zurückgegebene Feld currentTransform mit dem der derzeit gespeicherten Oberflächentransformation (in diesem Codebeispiel in pretransformFlag gespeichert).

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

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

Auf einem Google Pixel 4 mit Android 10 dauerte die AbfragevkGetPhysicalDeviceSurfaceCapabilitiesKHR() zwischen 0,120 und 0,250 ms. Auf einem Google Pixel 1 XL mit Android 8 dauerte die Abfrage 0,110 bis 0,350 ms.

Callbacks verwenden

Eine zweite Option für Geräte mit einer älteren Android-Version besteht darin, einen onNativeWindowResized()-Callback zu registrieren, um eine Funktion aufzurufen, die das Flag orientationChanged setzt und der Anwendung signalisiert, dass eine Ausrichtungsänderung stattgefunden hat:

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

Dabei ist ResizeCallback so definiert:

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

Das Problem bei dieser Lösung ist, dass onNativeWindowResized() nur bei einer 90-Grad-Ausrichtungsänderung aufgerufen wird, z. B. vom Querformat ins Hochformat oder umgekehrt. Andere Ausrichtungsänderungen lösen keine Neuerstellung des Swapchains aus. Eine Änderung von Querformat zu Hochformat wird beispielsweise nicht ausgelöst. In diesem Fall muss der Android-Kompositor die Umwandlung für Ihre Anwendung vornehmen.

Umgang mit der Ausrichtungsänderung

Rufen Sie zum Bearbeiten der Ausrichtungsänderung die Routine zum Ändern der Ausrichtung oben in der Haupt-Rendering-Schleife auf, wenn die Variable orientationChanged auf „wahr“ gesetzt ist. Beispiel:

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

Sie führen alle erforderlichen Schritte aus, um den Swapchain innerhalb der OnOrientationChange()-Funktion neu zu erstellen. Das bedeutet, dass Sie:

  1. Löschen Sie alle vorhandenen Instanzen von Framebuffer und ImageView.

  2. Erstellen Sie den Swapchain neu und löschen Sie den alten Swapchain (wird im nächsten Abschnitt erläutert).

  3. Erstellen Sie die Framebuffers mit den DisplayImages der neuen Swapchain neu. Hinweis:Anhängebilder (z. B. Tiefen-/Maskenbilder) müssen in der Regel nicht neu erstellt werden, da sie auf der Identitätsauflösung der vorab gedrehten Swapchain-Bilder basieren.

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

Am Ende der Funktion setzen Sie das orientationChanged-Flag auf „false“, um anzugeben, dass Sie die Änderung der Ausrichtung verarbeitet haben.

Swapchain-Wiederherstellung

Im vorherigen Abschnitt haben wir erwähnt, dass der Swapchain neu erstellt werden muss. Dazu müssen Sie zuerst die neuen Eigenschaften der Rendering-Oberfläche ermitteln:

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

Nachdem das VkSurfaceCapabilities-Objekt mit den neuen Informationen gefüllt ist, können Sie im Feld currentTransform prüfen, ob sich die Ausrichtung geändert hat. Sie speichern sie für später im Feld pretransformFlag, da Sie sie später benötigen, wenn Sie Anpassungen an der MVP-Matrix vornehmen.

Geben Sie dazu die folgenden Attribute im VkSwapchainCreateInfo-Attribut an:

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

Das Feld imageExtent wird mit dem displaySizeIdentity-Ausschnitt ausgefüllt, den Sie beim Starten der Anwendung gespeichert haben. Das Feld preTransform wird mit der Variablen pretransformFlag gefüllt, die auf das Feld „currentTransform“ der surfaceCapabilities festgelegt ist. Außerdem legen Sie das Feld oldSwapchain auf den Swapchain fest, der zerstört werden soll.

Anpassung der MVP-Matrix

Als Nächstes müssen Sie die Vortransformation anwenden, indem Sie eine Drehungsmatrix auf Ihre MVP-Matrix anwenden. Dabei wird die Drehung im Clipbereich angewendet, sodass das resultierende Bild in die aktuelle Geräteausrichtung gedreht wird. Sie können diese aktualisierte MVP-Matrix dann einfach an Ihren Vertex-Shader übergeben und wie gewohnt verwenden, ohne Ihre Shader ändern zu müssen.

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;

Hinweis: Vollbildmodus und Scherenschnitt

Wenn Ihre Anwendung einen nicht vollbildschirmfähigen Darstellungsbereich/Schnittbereich verwendet, müssen diese entsprechend der Ausrichtung des Geräts aktualisiert werden. Dazu müssen Sie die dynamischen Viewport- und Scissor-Optionen beim Erstellen der Vulkan-Pipeline aktivieren:

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

Die tatsächliche Berechnung des Viewport-Umfangs während der Aufzeichnung des Befehlspuffers sieht so aus:

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

Die Variablen x und y definieren die Koordinaten des linken oberen Eckpunkts des Darstellungsbereichs, während w und h die Breite und Höhe des Darstellungsbereichs definieren. Die gleiche Berechnung kann auch zum Festlegen des Scissor-Tests verwendet werden. Sie ist hier zur Vollständigkeit aufgeführt:

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

Hinweis: Fragment-Shader-Derivate

Wenn in Ihrer Anwendung abgeleitete Berechnungen wie dFdx und dFdy verwendet werden, sind möglicherweise zusätzliche Transformationen erforderlich, um das gedrehte Koordinatensystem zu berücksichtigen, da diese Berechnungen im Pixelbereich ausgeführt werden. Dazu muss die App eine Angabe zur Vorabtransformation in den Fragment-Shader übergeben (z. B. ein Ganzzahlwert, der die aktuelle Geräteausrichtung darstellt), um die abgeleiteten Berechnungen richtig abzubilden:

  • Für einen vorab um 90 Grad gedrehten Frame
    • dFdx muss dFdy zugeordnet werden.
    • dFdy muss -dFdx zugeordnet werden.
  • Für einen vorab um 270 Grad gedrehten Frame
    • dFdx muss -dFdy zugeordnet werden.
    • dFdy muss dFdx zugeordnet werden.
  • Für einen vorab um 180 Grad gedrehten Frame gilt:
    • dFdx muss -dFdx zugeordnet sein.
    • dFdy muss -dFdy zugeordnet werden.

Fazit

Damit Ihre Anwendung Vulkan auf Android optimal nutzen kann, ist die Implementierung der Vorrotation unerlässlich. Die wichtigsten Erkenntnisse aus diesem Artikel:

  • Achten Sie darauf, dass das Flag „pretransform“ beim Erstellen oder Neuerstellen des Swapchains mit dem vom Android-Betriebssystem zurückgegebenen Flag übereinstimmt. Dadurch wird der Overhead des Renderers vermieden.
  • Die Größe des Swap-Chains muss der Identitätsauflösung der Fensteroberfläche der App in der natürlichen Ausrichtung des Displays entsprechen.
  • Drehen Sie die MVP-Matrix im Clip-Raum, um die Geräteausrichtung zu berücksichtigen, da die Auflösung/Ausdehnung des Swap-Chains nicht mehr mit der Ausrichtung des Displays aktualisiert wird.
  • Aktualisieren Sie den Darstellungsbereich und die Scheren-Rechtecke nach Bedarf Ihrer Anwendung.

Beispiel-App:Minimale Android-Vorauswahl