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

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

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

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

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

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

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

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

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

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

כדי לוודא שפני השטח ימוחזרו בצורה תקינה עם מינימום תקורה, כפי שראינו במקרה הקודם, צריך להטמיע את השיטה 3. התהליך הזה נקרא רוטציה מראש. כך מערכת Android תדע שהאפליקציה מטפלת בסיבוב של המשטח. כדי לעשות זאת, מעבירים דגלים של טרנספורמציית משטח שמציינים את הכיוון במהלך יצירת שרשרת ההחלפות. הפעולה הזו מונעת מ-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, הפונקציה מחזירה את הערך VK_SUBOPTIMAL_KHR מ-vkQueuePresentKHR(). אנחנו שומרים את התוצאה של הבדיקה הזו ב-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() נמשכו בין 120 ל-250 אלפיות השנייה, וב-Pixel 1XL עם Android 8, הסקרים נמשכו בין 110 ל-350 אלפיות השנייה.

שימוש בקריאות חזרה

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

שיקול – חלון תצוגה וחיתוך שאינם במסך מלא

אם באפליקציה שלכם נעשה שימוש בחלון תצוגה או באזור חיתוך שלא מכסים את המסך במלואו, תצטרכו לעדכן אותם בהתאם לכיוון המכשיר. לשם כך, צריך להפעיל את האפשרויות הדינמיות 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, יכול להיות שתצטרכו לבצע טרנספורמציות נוספות כדי להביא בחשבון את מערכת הקואורדינטות המסובבת, כי החישובים האלה מתבצעים במרחב הפיקסלים. לשם כך, האפליקציה צריכה להעביר לאפליקציית ה-fragment shader אינדיקציה כלשהי של preTransform (למשל, מספר שלם שמייצג את כיוון המכשיר הנוכחי) ולהשתמש בה כדי למפות את החישובים של הנגזרות בצורה נכונה:

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

סיכום

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

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

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