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

Questo articolo descrive come gestire in modo efficiente la rotazione dei dispositivi nell'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 cose che vengono gestite dal driver in OpenGL, ad esempio l'orientamento del dispositivo e la sua relazione con l'orientamento della superficie di rendering. Android può gestire la riconciliazione della superficie di rendering del dispositivo con l'orientamento del dispositivo in tre modi:

  1. Il sistema operativo Android può utilizzare la DPU (Display Processing Unit) del dispositivo, che è in grado di gestire in modo efficiente la rotazione della superficie nell'hardware. Disponibile solo sui dispositivi supportati.
  2. Il sistema operativo Android può gestire la rotazione della superficie aggiungendo un pass 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 è in grado di gestire la rotazione della superficie eseguendo il rendering di un'immagine ruotata su una superficie di rendering corrispondente all'orientamento corrente del display.

Quale di questi metodi dovresti utilizzare?

Attualmente, un'applicazione non può sapere se la rotazione della superficie gestita all'esterno dell'applicazione è senza costi. Anche se c'è una DPU che si occupa di tutto questo, è probabile che ci sarà comunque una penalità misurabile per il rendimento. Se la tua applicazione è vincolata alla CPU, questo diventa un problema di alimentazione a causa dell'aumento dell'utilizzo della GPU da parte del compositore Android, che di solito viene eseguito a una frequenza incrementata. Se l'applicazione è vincolata alla GPU, il compositore Android può anche prerilasciare il funzionamento della GPU dell'applicazione, causando ulteriori perdite di prestazioni.

Durante l'esecuzione di titoli di spedizione su Pixel 4XL, abbiamo notato che SurfaceFlinger (l'attività con priorità più alta alla base del Compositor Android):

  • Prerilascia regolarmente il lavoro dell'applicazione, causando hit di 1-3 ms ai frame-time

  • Aumenta la pressione sulla memoria di vertice/texture della GPU, poiché il Compositor deve leggere l'intero framebuffer per svolgere il suo lavoro di composizione.

La gestione dell'orientamento interrompe correttamente il prerilascio della GPU da parte di SurfaceFlinger quasi del tutto, mentre la frequenza della GPU diminuisce del 40% poiché la frequenza potenziata utilizzata dal compositor Android non è più necessaria.

Per assicurarti che le rotazioni della superficie siano gestite correttamente con il minimo overhead possibile, come mostrato nel caso precedente, devi implementare il metodo 3. Questa operazione è nota come pre-rotazione. Questo indica al sistema operativo Android che la tua app gestisce la rotazione della superficie. Puoi farlo passando i flag di trasformazione di superficie che specificano l'orientamento durante la creazione della swapchain. Questa operazione impedisce al compositore Android di eseguire autonomamente la rotazione.

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 in cui la superficie di rendering si trova in un orientamento diverso rispetto a quello che il dispositivo considera come orientamento dell'identità. Ad esempio, un'applicazione solo per l'orientamento orizzontale su un telefono con identità verticale o un'applicazione solo per l'orientamento verticale su un tablet con identità orizzontale.

Modifica AndroidManifest.xml

Per gestire la rotazione dei dispositivi nell'app, inizia modificando il file AndroidManifest.xml dell'applicazione per comunicare ad Android che l'app gestirà le variazioni di orientamento e dimensioni dello schermo. In questo modo, Android non può distruggere e ricreare l'Activity di Android e chiamare la funzione onDestroy() sulla superficie della finestra esistente quando cambia l'orientamento. Per farlo, aggiungi 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 corregge l'orientamento dello schermo utilizzando l'attributo screenOrientation, non è necessario: Inoltre, se l'applicazione ha un orientamento fisso, sarà sufficiente configurare la swapchain solo una volta all'avvio/ripresa dell'applicazione.

Ottieni la risoluzione dello schermo dell'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à essere sempre impostata la swapchain. Il modo più affidabile per ottenere questo risultato è effettuare una chiamata a vkGetPhysicalDeviceSurfaceCapabilitiesKHR() all'avvio dell'applicazione e archiviare l'estensione restituita. Inverti 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 suddetta risoluzione dell'identità della superficie della finestra dell'app nell'orientamento naturale del display.

Rileva 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. Ecco alcuni esempi:

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 a partire dal giorno vkQueuePresentKHR(). Il risultato di questo controllo viene memorizzato in orientationChanged, un booleana cui è possibile accedere dal loop di rendering principale delle applicazioni.

Rileva le modifiche all'orientamento del dispositivo (precedenti ad Android 10)

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

Utilizzo dei sondaggi

Sui dispositivi precedenti ad Android 10 puoi eseguire il polling dell'attuale trasformazione del dispositivo ogni pollingInterval frame, dove pollingInterval è una granularità decisa dal programmatore. Per farlo, chiama vkGetPhysicalDeviceSurfaceCapabilitiesKHR() e 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 un Pixel 4 con Android 10, il polling vkGetPhysicalDeviceSurfaceCapabilitiesKHR() ha richiesto tra 0,120-0,250 ms e su un Pixel 1XL con Android 8, il sondaggio ha richiesto 0,110-0,350 ms.

Utilizzo delle richiamate

Una seconda opzione per i dispositivi con sistema operativo precedente ad Android 10 è registrare un callback onNativeWindowResized() per chiamare una funzione che imposta il flag orientationChanged, segnalando all'applicazione che si è verificato un cambiamento dell'orientamento:

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

Dove viene definito Ridimensiona Callback come:

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

Il problema di questa soluzione è che onNativeWindowResized() riceve solo cambiamenti di orientamento a 90 gradi, ad esempio passando da un'orientamento orizzontale a uno verticale o viceversa. Altre modifiche all'orientamento non attiveranno la ricreazione della swapchain. Ad esempio, il passaggio da Orizzontale a Orizzontale invertito non lo attiverà, e sarà necessario al compositore Android di eseguire il capovolgimento per l'applicazione.

Gestione del cambiamento di orientamento

Per gestire il cambio di orientamento, chiama la routine di modifica dell'orientamento nella parte superiore del loop di rendering principale quando la variabile orientationChanged è impostata su true. Ecco alcuni esempi:

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

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

  1. Elimina eventuali istanze esistenti di Framebuffer e ImageView,

  2. Ricrea la swapchain distruggendo quella precedente (di cui parleremo più avanti).

  3. Ricrea i Framebuffers con le DisplayImages della nuova swapchain. Nota: le immagini degli allegati (ad es. immagini di profondità/stencil) non devono essere ricreate in quanto si basano sulla risoluzione dell'identità delle immagini di swapchain pre-rotate.

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

Al termine della funzione hai reimpostato il flag orientationChanged su false per indicare che hai gestito il cambio di orientamento.

Attività ricreative

Nella sezione precedente abbiamo parlato di dover ricreare la swapchain. I primi passaggi per farlo comporta l'acquisizione delle nuove caratteristiche della superficie di rendering:

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

Con le nuove informazioni nello struct VkSurfaceCapabilities, ora puoi verificare se si è verificata una modifica dell'orientamento controllando il campo currentTransform. Lo memorizzerai per un secondo momento nel campo pretransformFlag perché ti servirà per un secondo momento quando modificherai la matrice MVP.

Per farlo, specifica i seguenti attributi nello 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 archiviata all'avvio dell'applicazione. Il campo preTransform verrà compilato con la variabile pretransformFlag (impostata sul campo currentTransform di surfaceCapabilities). Inoltre, imposterai il campo oldSwapchain sulla swapchain che verrà eliminata.

Aggiustamento della matrice MVP

L'ultima cosa da fare è applicare la pre-trasformazione applicando una matrice di rotazione alla matrice MVP. In pratica, si applica la rotazione nello spazio clip in modo che l'immagine risultante venga ruotata in base all'orientamento attuale del dispositivo. Puoi quindi semplicemente passare questa matrice MVP aggiornata nel tuo vertex Shader 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 forbice non a schermo intero

Se l'applicazione utilizza un'area visibile/scissor non a schermo intero, le informazioni dovranno essere aggiornate in base all'orientamento del dispositivo. Ciò richiede l'attivazione delle opzioni dinamiche del Viewport e della forbice 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 è simile al 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 dell'area visibile, mentre w e h definiscono rispettivamente la larghezza e l'altezza dell'area visibile. Lo stesso calcolo può essere utilizzato anche per impostare il test a forbice ed è incluso per la 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 - Derivati dello Shader frammentati

Se la tua applicazione utilizza calcoli derivati come dFdx e dFdy, potrebbero essere necessarie ulteriori trasformazioni per tenere conto del sistema di coordinate ruotate quando questi calcoli vengono eseguiti nello spazio dei pixel. Ciò richiede che l'app passi alcune indicazioni del valore di preTransform nello strumento di shadowing dei frammenti (ad esempio, un numero intero che rappresenta l'orientamento attuale del dispositivo) e di utilizzarlo per mappare correttamente i calcoli derivati:

  • Per una cornice pre-rotata di 90 gradi:
    • dFdx deve essere mappato a dFdy
    • dFdy deve essere mappato a -dFdx
  • Per una cornice pre-rotata di 270 gradi:
    • dFdx deve essere mappato a -dFdy
    • dFdy deve essere mappato a dFdx
  • Per una cornice pre-rotata di 180 gradi,
    • dFdx deve essere mappato a -dFdx
    • dFdy deve essere mappato a -dFdy

Conclusione

Per consentire alla tua applicazione di ottenere il massimo da Vulkan su Android, è necessario implementare la pre-rotazione. I concetti più importanti di questo articolo sono:

  • Assicurati che durante la creazione o la ricreazione della swapchain, il flag di pretrasformazione sia impostato in modo che corrisponda al flag restituito dal sistema operativo Android. Ciò eviterà l'overhead del compositore.
  • Mantieni le dimensioni della swapchain fissate 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 del dispositivo, perché la risoluzione/estensione della swapchain non si aggiorna più con l'orientamento del display.
  • Aggiorna l'area visibile e i rettangoli a forbice in base alle esigenze dell'applicazione.

App di esempio: Pre-rotazione Android minima