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