טיפול בכיוון המכשיר עם סיבוב מראש של Vulkan

במאמר הזה מוסבר איך לטפל ביעילות בסיבוב המכשיר באפליקציית Vulkan על ידי הטמעה של סיבוב מראש.

ב-Vulkan אפשר לציין הרבה יותר מידע על מצב הרינדור מאשר ב-OpenGL. ב-Vulkan, צריך להטמיע באופן מפורש דברים שמטופלים על ידי הדרייבר ב-OpenGL, כמו כיוון המכשיר והקשר שלו לכיוון של משטח הרינדור. יש שלוש דרכים שבהן Android יכול להתאים את משטח הרינדור של המכשיר לכיוון המכשיר:

  1. מערכת ההפעלה Android יכולה להשתמש ביחידת עיבוד התצוגה (DPU) של המכשיר, שיכולה לטפל ביעילות בסיבוב המסך בחומרה. האפשרות זמינה רק במכשירים נתמכים.
  2. מערכת ההפעלה Android יכולה לטפל בסיבוב של משטח על ידי הוספת מעבר של קומפוזיטור. הפעולה הזו תגבה מחיר מבחינת הביצועים, בהתאם לאופן שבו המלחין צריך להתמודד עם סיבוב תמונת הפלט.
  3. האפליקציה עצמה יכולה לטפל בסיבוב של משטח הרינדור על ידי רינדור של תמונה מסובבת על משטח רינדור שתואם לכיוון הנוכחי של המסך.

באיזו שיטה כדאי להשתמש?

כרגע אין לאפליקציה דרך לדעת אם סיבוב משטח שמתבצע מחוץ לאפליקציה יהיה חופשי. גם אם יש DPU שמטפל בזה, סביר להניח שעדיין תהיה פגיעה בביצועים. אם האפליקציה מוגבלת על ידי המעבד (CPU), זה הופך לבעיה של צריכת חשמל בגלל השימוש המוגבר ב-GPU על ידי Android Compositor, שבדרך כלל פועל בתדירות מוגברת. אם האפליקציה שלכם מוגבלת על ידי ה-GPU, אז גם Android Compositor יכול להקדים את עבודת ה-GPU של האפליקציה, מה שגורם לאובדן ביצועים נוסף.

כשמריצים משחקים ב-Pixel 4XL, ראינו ש-SurfaceFlinger (המשימה בעדיפות גבוהה יותר שמפעילה את Android Compositor):

  • מפריע באופן קבוע לפעולת האפליקציה, וגורם לעיכובים של 1-3 מילישניות בזמני הפריימים, וגם

  • מגביר את הלחץ על זיכרון הקודקודים/המרקמים של המעבד הגרפי, כי ה-Compositor צריך לקרוא את כל ה-framebuffer כדי לבצע את פעולת הקומפוזיציה.

טיפול נכון בכיוון המסך מונע כמעט לחלוטין את הפקעת ה-GPU על ידי SurfaceFlinger, ותדירות ה-GPU יורדת ב-40% כי אין יותר צורך בתדירות המוגברת שבה משתמש Android Compositor.

כדי לוודא שסיבובי המסך מטופלים בצורה תקינה עם כמה שפחות תקורה, כמו במקרה הקודם, צריך להטמיע את שיטה 3. התהליך הזה נקרא רוטציה מראש. כך מערכת ההפעלה Android יודעת שהאפליקציה שלכם מטפלת בסיבוב המשטח. כדי לעשות את זה, אפשר להעביר דגלים של שינוי פני השטח שמציינים את הכיוון במהלך היצירה של swapchain. הפעולה הזו מונעת מ-Android Compositor לבצע את הסיבוב בעצמו.

חשוב לדעת איך להגדיר את הדגל של טרנספורמציית המשטח בכל אפליקציית Vulkan. אפליקציות בדרך כלל תומכות בכמה כיוונים או בכיוון אחד, שבו משטח הרינדור נמצא בכיוון שונה מזה שהמכשיר מחשיב ככיוון הזהות שלו. לדוגמה, אפליקציה שפועלת רק במצב לרוחב בטלפון שפועל רק במצב לאורך, או אפליקציה שפועלת רק במצב לאורך בטאבלט שפועל רק במצב לרוחב.

שינוי הקובץ AndroidManifest.xml

כדי לטפל בסיבוב המכשיר באפליקציה, קודם צריך לשנות את הקובץ AndroidManifest.xml של האפליקציה כדי להודיע ל-Android שהאפליקציה תטפל בשינויים בכיוון ובגודל המסך. כך Android לא הורסת ויוצרת מחדש את ה-Activity של Android, ולא קוראת לפונקציה onDestroy() במשטח החלון הקיים כשמתרחש שינוי בכיוון. כדי לעשות את זה, מוסיפים את המאפיינים orientation (כדי לתמוך ברמת API <13) ו-screenSize לקטע configChanges של הפעילות:

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

אם האפליקציה שלכם מתקנת את כיוון המסך באמצעות המאפיין screenOrientation, אתם לא צריכים לעשות את זה. בנוסף, אם האפליקציה משתמשת בכיוון קבוע, צריך להגדיר את שרשרת ההחלפה רק פעם אחת בהפעלה או בהמשך של האפליקציה.

איך מקבלים את רזולוציית המסך של הזהות ואת הפרמטרים של המצלמה

בשלב הבא, מזהים את רזולוציית המסך של המכשיר שמשויכת לערך VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. הרזולוציה הזו משויכת לכיוון הזהות של המכשיר, ולכן תמיד צריך להגדיר את swapchain לרזולוציה הזו. הדרך הכי אמינה לקבל את המידע הזה היא לבצע קריאה ל-vkGetPhysicalDeviceSurfaceCapabilitiesKHR() כשהאפליקציה מופעלת, ולשמור את המידע שמוחזר. כדי לוודא שאתם שומרים את רזולוציית המסך של הזהות, החליפו בין הרוחב והגובה על סמך הערך של currentTransform שגם מוחזר:

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 הוא מבנה VkExtent2D שבו אנחנו משתמשים כדי לאחסן את הרזולוציה של שטח החלון של האפליקציה בכיוון הטבעי של המסך.

זיהוי שינויים בכיוון המכשיר (Android מגרסה 10 ואילך)

הדרך הכי אמינה לזהות שינוי בכיוון באפליקציה היא לבדוק אם הפונקציה vkQueuePresentKHR() מחזירה את הערך VK_SUBOPTIMAL_KHR. לדוגמה:

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

הערה: הפתרון הזה פועל רק במכשירים עם Android מגרסה 10 ואילך. בגרסאות האלה של Android, הפונקציה vkQueuePresentKHR() מחזירה את הערך VK_SUBOPTIMAL_KHR. אנחנו שומרים את תוצאת הבדיקה הזו ב-orientationChanged, boolean שאפשר לגשת אליו מלולאת הרינדור הראשית של האפליקציות.

זיהוי שינויים בכיוון המכשיר (לפני Android 10)

במכשירים עם Android מגרסה 10 או גרסאות ישנות יותר, צריך הטמעה אחרת כי אין תמיכה ב-VK_SUBOPTIMAL_KHR.

שימוש בסקרים

במכשירים עם גרסאות קודמות ל-Android 10, אפשר לדגום את הטרנספורמציה הנוכחית של המכשיר בכל pollingInterval פריימים, כאשר pollingInterval הוא רמת הפירוט שנקבעה על ידי המתכנת. כדי לעשות את זה, צריך להפעיל את vkGetPhysicalDeviceSurfaceCapabilitiesKHR() ואז להשוות את השדה currentTransform שמוחזר עם השדה של טרנספורמציית המשטח שמאוחסנת כרגע (בדוגמת הקוד הזו מאוחסנת ב-pretransformFlag).

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

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

במכשיר Pixel 4 עם Android 10, הסקר vkGetPhysicalDeviceSurfaceCapabilitiesKHR() נמשך בין 0 .120 ל-0.250 אלפיות השנייה, ובמכשיר Pixel 1XL עם Android 8, הסקר נמשך בין 0 .110 ל-0.350 אלפיות השנייה.

שימוש בקריאות חוזרות (callbacks)

אפשרות שנייה למכשירים עם גרסה ישנה יותר מ-Android 10 היא לרשום קריאה חוזרת (callback) של onNativeWindowResized() כדי להפעיל פונקציה שמגדירה את הדגל orientationChanged, וכך לאותת לאפליקציה שחל שינוי בכיוון:

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

כאשר ResizeCallback מוגדר כך:

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

הבעיה בפתרון הזה היא שהפונקציה onNativeWindowResized() נקראת רק בשינויי כיוון של 90 מעלות, כמו מעבר ממצב לרוחב למצב לאורך או להיפך. שינויים אחרים בכיוון לא יגרמו ליצירה מחדש של שרשרת ההחלפה. לדוגמה, שינוי ממצב לרוחב למצב לרוחב הפוך לא יפעיל את האירוע, והמערכת תדרוש מהקומפוזיטור של Android להפוך את האפליקציה.

טיפול בשינוי הכיוון

כדי לטפל בשינוי הכיוון, צריך להפעיל את שגרת שינוי הכיוון בחלק העליון של לולאת הרינדור הראשית כשהמשתנה orientationChanged מוגדר כ-true. לדוגמה:

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

אתם מבצעים את כל העבודה הנדרשת כדי ליצור מחדש את שרשרת ההחלפה בפונקציה OnOrientationChange(). כלומר:

  1. להשמיד את כל המופעים הקיימים של Framebuffer ו-ImageView,

  2. יוצרים מחדש את שרשרת ההחלפה תוך כדי השמדת שרשרת ההחלפה הישנה (שנדון בה בהמשך), וגם

  3. יוצרים מחדש את ה-Framebuffers באמצעות ה-DisplayImages של ה-swapchain החדש. הערה: בדרך כלל לא צריך ליצור מחדש תמונות מצורפות (למשל, תמונות עומק או סטנסיל), כי הן מבוססות על רזולוציית הזהות של תמונות שרשרת ההחלפה שסובבו מראש.

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

בסוף הפונקציה, מאפסים את הדגל orientationChanged ל-false כדי לציין שהטיפול בשינוי הכיוון הסתיים.

יצירה מחדש של Swapchain

בקטע הקודם ציינו שצריך ליצור מחדש את שרשרת ההחלפה. השלבים הראשונים הם קבלת המאפיינים החדשים של משטח העיבוד:

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

אחרי שמאכלסים את המבנה VkSurfaceCapabilities במידע החדש, אפשר לבדוק אם חל שינוי בכיוון המסך באמצעות השדה currentTransform. שומרים אותו בשדה pretransformFlag כי תצטרכו אותו בהמשך כשתבצעו שינויים במטריצת ה-MVP.

כדי לעשות את זה, מציינים את המאפיינים הבאים במבנה 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);
}

השדה imageExtent יאוכלס בערך של displaySizeIdentity ששמרתם כשהאפליקציה הופעלה. השדה preTransform יאוכלס במשתנה pretransformFlag (שמוגדר לשדה currentTransform של surfaceCapabilities). אתם גם מגדירים את השדה oldSwapchain ל-swapchain שיושמד.

התאמת מטריצת MVP

הדבר האחרון שצריך לעשות הוא להחיל את הטרנספורמציה המקדימה על ידי החלת מטריצת סיבוב על מטריצת ה-MVP. הפעולה הזו בעצם מחילה את הסיבוב במרחב הקליפ, כך שהתמונה שמתקבלת מסובבת לכיוון הנוכחי של המכשיר. לאחר מכן, תוכלו פשוט להעביר את מטריצת ה-MVP המעודכנת הזו ל-vertex shader ולהשתמש בה כרגיל בלי לשנות את ה-shaders.

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;

נקודה למחשבה – אזור תצוגה וגזירה שלא במסך מלא

אם האפליקציה משתמשת באזור תצוגה או באזור חיתוך שלא מוצגים במסך מלא, צריך לעדכן אותם בהתאם לכיוון של המכשיר. לשם כך, צריך להפעיל את האפשרויות הדינמיות Viewport ו-Scissor במהלך יצירת צינור עיבוד הנתונים של 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);

החישוב בפועל של היקף אזור התצוגה במהלך הקלטת מאגר הפקודות נראה כך:

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

המשתנים x ו-y מגדירים את הקואורדינטות של הפינה השמאלית העליונה של אזור התצוגה, והמשתנים w ו-h מגדירים את הרוחב והגובה של אזור התצוגה, בהתאמה. אפשר להשתמש באותו חישוב גם כדי להגדיר את בדיקת המספריים, והוא נכלל כאן כדי להשלים את התמונה:

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

שיקול – נגזרות של Fragment Shader

אם האפליקציה משתמשת בחישובים נגזרים כמו dFdx ו-dFdy, יכול להיות שיהיה צורך בטרנספורמציות נוספות כדי להביא בחשבון את מערכת הקואורדינטות המסובבת, כי החישובים האלה מבוצעים במרחב הפיקסלים. לשם כך, האפליקציה צריכה להעביר אינדיקציה כלשהי של preTransform ל-fragment shader (למשל מספר שלם שמייצג את כיוון המכשיר הנוכחי) ולהשתמש בה כדי למפות את חישובי הנגזרות בצורה נכונה:

  • לפריים שסובב מראש ב-90 מעלות
    • צריך למפות את dFdx ל-dFdy
    • dFdy חייב להיות ממופה ל--dFdx
  • לפריים שסובב מראש ב-270 מעלות
    • צריך למפות את dFdx ל--dFdy
    • צריך למפות את dFdy ל-dFdx
  • לפריים שסובב מראש ב-180 מעלות,
    • צריך למפות את dFdx ל--dFdx
    • ‫dFdy צריך להיות ממופה ל--dFdy

סיכום

כדי שהאפליקציה תפיק את המרב מ-Vulkan ב-Android, חובה להטמיע סיבוב מראש. הנקודות החשובות ביותר במאמר הזה הן:

  • חשוב לוודא שבמהלך היצירה או היצירה מחדש של swapchain, הדגל pretransform מוגדר כך שיתאים לדגל שמוחזר על ידי מערכת ההפעלה של Android. כך אפשר להימנע מהתקורה של ה-compositor.
  • שומרים על גודל קבוע של שרשרת ההחלפה (swapchain) בהתאם לרזולוציית הזיהוי של משטח החלון של האפליקציה בכיוון הטבעי של המסך.
  • מסובבים את מטריצת ה-MVP במרחב החיתוך כדי להביא בחשבון את כיוון המכשיר, כי הרזולוציה או המידה של swapchain כבר לא מתעדכנות לפי כיוון המסך.
  • מעדכנים את המלבנים של אזור התצוגה והחיתוך לפי הצורך של האפליקציה.

אפליקציה לדוגמה: סיבוב מראש מינימלי ב-Android