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

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

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

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

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

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

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

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

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

זיהוי שינויים בכיוון המכשיר (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.

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

התעניינות ברכישה – מספריים ואזור תצוגה שאינם במסך מלא

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

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

סיכום

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

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

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