במאמר הזה מוסבר איך לטפל ביעילות בסיבוב המכשיר באפליקציית Vulkan באמצעות הטמעה של סיבוב מראש.
עם Vulkan, אפשר לציין הרבה יותר מידע על מצב הרינדור מאשר עם OpenGL. ב-Vulkan, צריך להטמיע באופן מפורש דברים שמטופלים על ידי מנהל ההתקן ב-OpenGL, כמו הכיוון של המכשיר והקשר שלו לכיוון של משטח הרינדור. יש שלוש דרכים שבהן מערכת Android יכולה להתאים את משטח העיבוד של המכשיר לכיוון המכשיר:
- מערכת ההפעלה Android יכולה להשתמש ביחידת עיבוד התצוגה (DPU) של המכשיר, שמסוגלת לטפל ביעילות בסיבוב של משטחים בחומרה. זמין רק במכשירים נתמכים.
- מערכת Android OS יכולה לטפל בסיבוב של משטח על ידי הוספת מעבר של שכבת קומפוזיציה. הפעולה הזו תגרום לירידה בביצועים, בהתאם לאופן שבו המחבר צריך להתמודד עם סיבוב תמונת הפלט.
- האפליקציה עצמה יכולה לטפל בסיבוב של המשטח על ידי עיבוד של תמונה מסובבת על משטח עיבוד שתואם לכיוון הנוכחי של התצוגה.
באיזו מהשיטות האלה כדאי להשתמש?
בשלב הזה, אין דרך לאפליקציה לדעת אם סיבוב המשטח שמטופל מחוץ לאפליקציה יהיה בחינם. גם אם יש 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()
. כלומר, אתם:
השמדת כל המופעים הקיימים של
Framebuffer
ושלImageView
,ליצור מחדש את שרשרת ההחלפה תוך השמדת שרשרת ההחלפה הישנה (שנדון בה בהמשך), וגם
יוצרים מחדש את מאגרי הפיקסלים עם 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. השלבים הראשונים כדי לעשות זאת הם קבלת המאפיינים החדשים של אזור התצוגה:
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);
שיקול – נגזרות של הצללה של מקטעים
אם האפליקציה משתמשת בחישובים נגזרים כמו dFdx
ו-dFdy
, יכול להיות שיהיה צורך בטרנספורמציות נוספות כדי להתחשב במערכת הקואורדינטות המסובבת, כי החישובים האלה מבוצעים במרחב הפיקסלים. לשם כך, האפליקציה צריכה להעביר אינדיקציה כלשהי של preTransform אל fragment shader (למשל, מספר שלם שמייצג את האוריינטציה הנוכחית של המכשיר) ולהשתמש בה כדי למפות את חישובי הנגזרת בצורה נכונה:
- למסגרת שסובבה מראש ב-90 מעלות
- צריך למפות את dFdx ל-dFdy
- צריך למפות את dFdy אל -dFdx
- למסגרת שסובבה מראש ב-270 מעלות:
- צריך למפות את dFdx ל--dFdy
- צריך למפות את dFdy אל dFdx
- לפריים מסובב מראש ב-180 מעלות:
- צריך למפות את dFdx אל -dFdx
- dFdy צריך להיות ממופה ל-dFdy-
סיכום
כדי להפיק את המרב מ-Vulkan ב-Android באפליקציה שלכם, חובה להטמיע סיבוב מראש. הנקודות החשובות ביותר במאמר הזה:
- חשוב לוודא שבמהלך יצירה או יצירה מחדש של שרשרת החלפה, הדגל של טרום-ההמרה מוגדר כך שיתאים לדגל שמוחזר על ידי מערכת ההפעלה של Android. כך נמנעים מתקורה של קומפוזיטור.
- צריך להשאיר את הגודל של swapchain קבוע בהתאם לפתרון הזהות של חלון האפליקציה במשטח הכיוון הטבעי של המסך.
- מסובבים את מטריצת ה-MVP במרחב הקליפ כדי להתאים את כיוון המכשיר, כי הרזולוציה או ההיקף של ה-swapchain כבר לא מתעדכנים בהתאם לכיוון התצוגה.
- מעדכנים את תיבות התצוגה ואת המלבנים של כלי החיתוך לפי הצורך של האפליקציה.
אפליקציה לדוגמה: סיבוב מקדים מינימלי ב-Android