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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  3. יוצרים מחדש את מאגרי המסגרות באמצעות 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 המעודכנת הזו לשיידר של הקודקוד ולהשתמש בה כרגיל, בלי לשנות את השיידרים.

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

שיקול – נגזרות של Shader של מקטע

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

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

סיכום

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

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

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