Gestisci l'orientamento del dispositivo con la pre-rotazione Vulkan

Questo articolo descrive come gestire in modo efficiente la rotazione del dispositivo nella tua applicazione Vulkan implementando la pre-rotazione.

Con Vulkan, puoi specificare molte più informazioni sullo stato di rendering rispetto a OpenGL. Con Vulkan, devi implementare esplicitamente le funzionalità gestite dal driver in OpenGL, ad esempio l'orientamento del dispositivo e la sua relazione con l'orientamento della superficie di rendering. Esistono tre modi in cui Android può gestire la riconciliazione della superficie di rendering del dispositivo con l'orientamento del dispositivo:

  1. Il sistema operativo Android può utilizzare la Display Processing Unit (DPU) del dispositivo, che può gestire in modo efficiente la rotazione della superficie in hardware. Disponibile solo su dispositivi supportati.
  2. Il sistema operativo Android può gestire la rotazione della superficie aggiungendo un passaggio del compositore. Ciò avrà un costo in termini di prestazioni, a seconda di come il compositore deve gestire la rotazione dell'immagine di output.
  3. L'applicazione stessa può gestire la rotazione della superficie eseguendo il rendering di un'immagine ruotata su una superficie di rendering che corrisponde all'orientamento corrente del display.

Quale di questi metodi dovresti utilizzare?

Al momento, non è possibile per un'applicazione sapere se la rotazione della superficie gestita al di fuori dell'applicazione sarà libera. Anche se esiste un DPU che si occupa di tutto per te, è probabile che dovrai comunque pagare una penalizzazione del rendimento misurabile. Se la tua applicazione è vincolata alla CPU, si tratta di un problema di alimentazione a causa dell'aumento dell'utilizzo della GPU da parte di Android Compositor, che di solito viene eseguito a una frequenza potenziata. Se la tua applicazione è vincolata alla GPU, il compositore Android può anche anticipare il lavoro della GPU dell'applicazione, causando un'ulteriore perdita di prestazioni.

Quando eseguiamo i titoli di spedizione su Pixel 4XL, abbiamo notato che SurfaceFlinger (l'attività con priorità più elevata che gestisce Android Compositor):

  • Interrompe regolarmente il lavoro dell'applicazione, causando picchi di 1-3 ms ai tempi di frame e

  • Aumenta la pressione sulla memoria vertex/texture della GPU, perché il compositore deve leggere l'intero framebuffer per eseguire la composizione.

La gestione dell'orientamento interrompe quasi completamente la preemption della GPU da parte di SurfaceFlinger, mentre la frequenza della GPU cala del 40% poiché la frequenza potenziata utilizzata dal compositore Android non è più necessaria.

Per assicurarti che le rotazioni delle superfici vengano gestite correttamente con il minimo overhead possibile, come nel caso precedente, devi implementare il metodo 3. Questa operazione è nota come pre-rotazione. In questo modo, il sistema operativo Android viene informato che la tua app gestice la rotazione del dispositivo. Puoi farlo passando i flag di trasformazione della superficie che specificano l'orientamento durante la creazione della swapchain. In questo modo, impedisci al compositore Android di eseguire la rotazione in prima persona.

Sapere come impostare il flag di trasformazione della superficie è importante per ogni applicazione Vulkan. Le applicazioni tendono a supportare più orientamenti o un singolo orientamento se la superficie di rendering è in un orientamento diverso da quello considerato dal dispositivo come orientamento dell'identità. Ad esempio, un'applicazione solo in orizzontale su uno smartphone con identità verticale o un'applicazione solo in verticale su un tablet con identità orizzontale.

Modificare AndroidManifest.xml

Per gestire la rotazione del dispositivo nella tua app, inizia modificando il file AndroidManifest.xml dell'applicazione per indicare ad Android che la tua app gestirà le modifiche dell'orientamento e delle dimensioni dello schermo. In questo modo, Android non distrugge e ricrea Activity e non chiama la funzione onDestroy() sulla superficie della finestra esistente quando si verifica un cambio di orientamento. Per farlo, aggiungere gli attributi orientation (per supportare il livello API <13) e screenSize alla sezione configChanges dell'attività:

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

Se la tua applicazione fissa l'orientamento dello schermo utilizzando l'attributo screenOrientation, non è necessario farlo. Inoltre, se la tua applicazione utilizza un orientamento fisso, dovrà configurare la swapchain una sola volta all'avvio/al riavvio dell'applicazione.

Ottenere la risoluzione dello schermo di identità e i parametri della fotocamera

Successivamente, rileva la risoluzione dello schermo del dispositivo associata al valore VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. Questa risoluzione è associata all'orientamento dell'identità del dispositivo ed è quindi quella su cui dovrà sempre essere impostata la swapchain. Il modo più affidabile per ottenere questo risultato è effettuare una chiamata a vkGetPhysicalDeviceSurfaceCapabilitiesKHR() all'avvio dell'applicazione e memorizzare l'ambito restituito. Scambia la larghezza e l'altezza in base al valore currentTransform restituito per assicurarti di memorizzare la risoluzione dello schermo dell'identità:

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 è una struttura VkExtent2D che utilizziamo per memorizzare la risoluzione dell'identità della superficie della finestra dell'app nell'orientamento naturale del display.

Rilevare le modifiche all'orientamento del dispositivo (Android 10 e versioni successive)

Il modo più affidabile per rilevare una modifica dell'orientamento nell'applicazione è verificare se la funzione vkQueuePresentKHR() restituisce VK_SUBOPTIMAL_KHR. Ad esempio:

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

Nota: questa soluzione funziona solo sui dispositivi con Android 10 e versioni successive. Queste versioni di Android restituiscono VK_SUBOPTIMAL_KHR da vkQueuePresentKHR(). Memorizziamo il risultato di questo controllo in orientationChanged, un boolean accessibile dal loop di rendering principale delle applicazioni.

Rilevare le variazioni dell'orientamento del dispositivo (versioni precedenti ad Android 10)

Per i dispositivi con Android 10 o versioni precedenti, è necessaria un'implementazione diversa, perché VK_SUBOPTIMAL_KHR non è supportato.

Utilizzare il polling

Sui dispositivi precedenti ad Android 10 puoi eseguire il polling della trasformazione del dispositivo corrente ogni pollingInterval frame, dove pollingInterval è una granularità decisa dal programmatore. Per farlo, chiama vkGetPhysicalDeviceSurfaceCapabilitiesKHR() e poi confronta il campo currentTransform restituito con quello della trasformazione della superficie attualmente archiviata (in questo esempio di codice archiviato in pretransformFlag).

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

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

Su Pixel 4 con Android 10, il pollingvkGetPhysicalDeviceSurfaceCapabilitiesKHR() è durato tra 0,120 e 0,250 ms, mentre su Pixel 1XL con Android 8 è durato tra 0,110 e 0,350 ms.

Utilizzo dei Callback

Una seconda opzione per i dispositivi con versioni precedenti ad Android 10 è registrare un callback onNativeWindowResized() per chiamare una funzione che imposta il orientationChanged flag, segnalando all'applicazione che si è verificata una variazione dell'orientamento:

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

Dove ResizeCallback è definito come:

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

Il problema di questa soluzione è che onNativeWindowResized() viene chiamato solo per le modifiche dell'orientamento di 90 gradi, ad esempio dal formato orizzontale a quello verticale o viceversa. Altre modifiche all'orientamento non attiveranno la ricreazione della swapchain. Ad esempio, non verrà attivata se passi dal formato orizzontale a quello verticale e il compositore Android dovrà eseguire la rotazione per la tua applicazione.

Gestione della modifica dell'orientamento

Per gestire la modifica dell'orientamento, chiama la routine di modifica dell'orientamento nella parte superiore del loop di rendering principale quando la variabile orientationChanged è impostata su true. Ad esempio:

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

Devi eseguire tutto il lavoro necessario per ricreare la swapchain all'interno della funzione OnOrientationChange(). Ciò significa che:

  1. Distruggi tutte le istanze esistenti di Framebuffer e ImageView.

  2. Ricrea la swapchain distruggendo la vecchia swapchain (di cui parleremo più avanti) e

  3. Ricrea i framebuffer con le DisplayImages della nuova swapchain. Nota:in genere non è necessario ricreare le immagini degli allegati (ad esempio le immagini di profondità/stencil) perché si basano sulla risoluzione dell'identità delle immagini della catena di scambio pre-ruotate.

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

Alla fine della funzione, reimposta il flag orientationChanged su false per indicare che hai gestito la modifica dell'orientamento.

Ricostruzione della catena di scambio

Nella sezione precedente abbiamo accennato alla necessità di ricreare la swapchain. I primi passaggi per farlo consistono nell'ottenere le nuove caratteristiche della superficie di rendering:

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

Con la struttura VkSurfaceCapabilities compilata con le nuove informazioni, ora puoi verificare se si è verificata una modifica dell'orientamento controllando il campo currentTransform. Lo memorizzerai per utilizzarlo in un secondo momento nel campo pretransformFlag poiché ti servirà quando apporterai modifiche alla matrice MVP.

A tale scopo, specifica i seguenti attributi nella struct 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);
}

Il campo imageExtent verrà compilato con l'estensione displaySizeIdentity memorizzata all'avvio dell'applicazione. Il campo preTransform verrà compilato con la variabile pretransformFlag (impostata sul campo currentTransform di surfaceCapabilities). Imposti anche il campo oldSwapchain sulla swapchain che verrà distrutta.

Modifica della matrice MVP

L'ultima cosa da fare è applicare la pre-trasformazione mediante una matrice di rotazione alla matrice MVP. In sostanza, viene applicata la rotazione nello spazio del clip in modo che l'immagine risultante venga ruotata in base all'orientamento corrente del dispositivo. Puoi quindi semplicemente passare questa matrice MVP aggiornata al tuo shader vertex e utilizzarla normalmente senza dover modificare gli shader.

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;

Considerazione: viewport e scissor non a schermo intero

Se la tua applicazione utilizza una regione viewport/scissor non a schermo intero, dovrà essere aggiornata in base all'orientamento del dispositivo. Per farlo, devi attivare le opzioni Viewport e Scissor dinamiche durante la creazione della pipeline di 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);

Il calcolo effettivo dell'estensione dell'area visibile durante la registrazione del buffer dei comandi è il seguente:

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

Le variabili x e y definiscono le coordinate dell'angolo in alto a sinistra del viewport, mentre w e h definiscono rispettivamente la larghezza e l'altezza del viewport. Lo stesso calcolo può essere utilizzato anche per impostare il test a forbice ed è incluso qui per completezza:

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

Considerazione: derivate di shader di frammenti

Se la tua applicazione utilizza calcoli derivati come dFdx e dFdy, potrebbero essere necessarie trasformazioni aggiuntive per tenere conto del sistema di coordinate girato poiché questi calcoli vengono eseguiti nello spazio dei pixel. Per farlo, l'app deve trasmettere un'indicazione della preTransform allo shader di frammento (ad esempio un numero intero che rappresenta l'orientamento corrente del dispositivo) e utilizzarla per mappare correttamente i calcoli delle derivate:

  • Per un frame ruotato in precedenza di 90 gradi
    • dFdx deve essere mappato a dFdy
    • dFdy deve essere mappato a -dFdx
  • Per un frame pre-rotato di 270 gradi
    • dFdx deve essere mappato a -dFdy
    • dFdy deve essere mappato a dFdx
  • Per un fotogramma pre-ruotato di 180 gradi,
    • dFdx deve essere mappato a -dFdx
    • dFdy deve essere mappato a -dFdy

Conclusione

Affinché la tua applicazione possa sfruttare al meglio Vulkan su Android, è necessario implementare la pre-rotazione. I punti più importanti di questo articolo sono:

  • Assicurati che durante la creazione o la ricreazione della catena di scambio, il flag pretransform sia impostato in modo da corrispondere al flag restituito dal sistema operativo Android. In questo modo eviterai il sovraccarico del compositore.
  • Mantieni le dimensioni della catena di scambio fisse alla risoluzione dell'identità della superficie della finestra dell'app nell'orientamento naturale del display.
  • Ruota la matrice MVP nello spazio clip per tenere conto dell'orientamento dei dispositivi, poiché la risoluzione/l'estensione della catena di scambio non si aggiorna più con l'orientamento del display.
  • Aggiorna il viewport e i rettangoli di ritaglio in base alle esigenze della tua applicazione.

App di esempio: prerotazione Android minima