במאמר הזה נסביר איך לטפל ביעילות בסיבוב המכשיר באפליקציית Vulkan באמצעות הטמעת סיבוב מראש.
באמצעות Vulkan אפשר לציין הרבה יותר מידע על מצב העיבוד מאשר ב-OpenGL. ב-Vulkan, צריך להטמיע באופן מפורש דברים שמנוהלים על ידי מנהל ההתקן ב-OpenGL, כמו כיוון המכשיר והקשר שלו לכיוון של משטח הרינדור. יש שלוש דרכים שבהן מערכת Android יכולה לטפל בהתאמת שטח העיבוד של המכשיר לפי כיוון המכשיר:
- מערכת ההפעלה של Android יכולה להשתמש ביחידה לעיבוד תצוגה (DPU) של המכשיר, שיכולה לטפל ביעילות בסיבוב של משטח בחומרה. האפשרות זמינה רק במכשירים נתמכים.
- מערכת ההפעלה של Android יכולה לטפל בסיבוב של משטחים על ידי הוספת מעבר של מעבד גרפי. לפעולה הזו תהיה עלות ביצועים, בהתאם לאופן שבו ה-Compositing יטפל בתמונה הפלט.
- האפליקציה עצמה יכולה לטפל בסיבוב של פני השטח על ידי עיבוד תמונה מסובבת על גבי משטח עיבוד שמתאים לכיוון הנוכחי של המסך.
באיזו שיטה כדאי להשתמש?
בשלב זה אין לאפליקציה דרך לדעת אם סיבוב של משטח שמטופל מחוץ לאפליקציה יהיה בחינם. גם אם יש צורך ב-DPU שיטפל בעניין הזה, עדיין צפוי לשלם עונש ניתן למדידה על הביצועים. אם האפליקציה שלכם מוגבלת למעבד, הבעיה הופכת לבעיית צריכת חשמל בגלל השימוש המוגבר ב-GPU על ידי Android Compositor, שמריץ בדרך כלל בתדירות מוגברת. אם האפליקציה שלכם מוגבלת ל-GPU, Android Compositor יכול גם לקבל עדיפות על פני עבודת ה-GPU של האפליקציה, וכך לגרום לירידה נוספת בביצועים.
כשהרצנו כותרים שזמינים במכשיר Pixel 4XL, גילינו ש-SurfaceFlinger (המשימה בעדיפות גבוהה יותר שמפעילה את Android Compositor):
מכבה באופן קבוע את עבודת האפליקציה, וגורמת להיטים של 1-3 אלפיות השנייה עבור זמני רינדור פריים,
הפעלת לחץ מוגבר על זיכרון הקודקוד/המרקם של ה-GPU, כי הקומפוזיטור צריך לקרוא את כל הפריים-חוצץ כדי לבצע את עבודת ההרכבה.
טיפול נכון בכיוון מפסיק כמעט לחלוטין את הקצאת העדיפות מראש ל-GPU על ידי SurfaceFlinger, בעוד שתדירות ה-GPU יורדת ב-40% כי אין יותר צורך בתדירות המוגברת שבה משתמש Android Compositor.
כדי לוודא שפני השטח ימוחזרו בצורה תקינה עם מינימום תקורה, כפי שראינו במקרה הקודם, צריך להטמיע את השיטה 3. התהליך הזה נקרא רוטציה מראש. כך מערכת Android תדע שהאפליקציה מטפלת בסיבוב של המשטח. כדי לעשות זאת, מעבירים דגלים של טרנספורמציית משטח שמציינים את הכיוון במהלך יצירת שרשרת ההחלפות. הפעולה הזו מונעת את ביצוע הרוטציה בעצמו.
חשוב לדעת איך להגדיר את הדגל של טרנספורמציית הפנים בכל אפליקציית 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()
. כלומר, אתם יכולים:
מוחקים את כל המופעים הקיימים של
Framebuffer
ו-ImageView
,ליצור מחדש את שרשרת ההחלפה תוך השמדת שרשרת ההחלפה הישנה (כפי שיתואר בהמשך), וגם
צור מחדש את מאגרי הפריימים עם תמונות DisplayImages של ה-Displayimages של רשת ההחלפה החדשה. הערה: בדרך כלל אין צורך ליצור מחדש תמונות של קבצים מצורפים (למשל, תמונות עומק או סטנסיל), כי הן מבוססות על רזולוציית הזהות של התמונות שסובבו מראש.
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 המעודכנת לשדה הקוד של ה-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;
שיקול – חלון תצוגה וחיתוך שאינם במסך מלא
אם באפליקציה שלכם נעשה שימוש בחלון תצוגה או באזור חיתוך שלא מכסים את המסך במלואו, תצטרכו לעדכן אותם בהתאם לכיוון המכשיר. לשם כך, צריך להפעיל את האפשרויות האלה בזמן יצירת צינור עיבוד הנתונים של 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 בצורה הטובה ביותר, חובה להטמיע טרום-רוטציה. המסקנות החשובות ביותר מהמאמר הזה הן:
- חשוב לוודא שבמהלך היצירה או היצירה מחדש של שרשרת ההחלפה, הדגל של טרנספורמציה מראש מוגדר כך שיתאים לדגל שהוחזר על ידי מערכת ההפעלה של Android. כך תוכלו להימנע מהעלויות הנוספות של המאגר.
- חשוב שגודל ה-החלפה יהיה קבוע לרזולוציית הזהות של שטח החלון של האפליקציה בכיוון הטבעי של המסך.
- מסובבים את מטריצת ה-MVP במרחב החיתוך כדי להביא בחשבון את כיוון המכשיר, כי רזולוציית ה-swapchain או ההיקף שלה כבר לא מתעדכנים בהתאם לכיוון המסך.
- מעדכנים את חלון התצוגה ואת ריבועי החיתוך בהתאם לצורכי האפליקציה.
אפליקציה לדוגמה: רוטציה מינימלית מראש ב-Android