مدیریت جهت دستگاه با پیش‌چرخش Vulkan

این مقاله نحوه مدیریت کارآمد چرخش دستگاه را در برنامه Vulkan با پیاده‌سازی پیش‌چرخش شرح می‌دهد.

با Vulkan، می‌توانید اطلاعات بسیار بیشتری درباره وضعیت پرداز نسبت‌به OpenGL مشخص کنید. با Vulkan، باید چیزهایی را که توسط درایور در OpenGL مدیریت می‌شوند، به‌طور صریح پیاده‌سازی کنید، مانند جهت دستگاه و رابطه آن با جهت سطح پردازش. سه روش وجود دارد که Android می‌تواند تطبیق سطح رندر دستگاه را با جهت دستگاه مدیریت کند:

  1. سیستم‌عامل Android می‌تواند از «واحد پردازش نمایشگر» (DPU) دستگاه استفاده کند، که می‌تواند به‌طور مؤثری چرخش سطح را در سخت‌افزار مدیریت کند. فقط در دستگاه‌های پشتیبانی‌شده دردسترس است.
  2. سیستم‌عامل Android می‌تواند با افزودن یک گذر ترکیب‌کننده، چرخش سطح را مدیریت کند. این بسته به اینکه ترکیب‌کننده چگونه باید با چرخاندن تصویر خروجی برخورد کند، هزینه عملکردی خواهد داشت.
  3. خود برنامه می‌تواند با رندر کردن تصویر چرخیده‌شده روی سطح رندر که با جهت فعلی نمایشگر مطابقت دارد، چرخش سطح را مدیریت کند.

از کدام‌یک از این روش‌ها باید استفاده کنید؟

درحال‌حاضر، هیچ راهی وجود ندارد که برنامه بداند آیا چرخش سطح که خارج از برنامه انجام می‌شود رایگان خواهد بود یا نه. حتی اگر DPU برای رسیدگی به این موضوع وجود داشته باشد، احتمالاً همچنان جریمه عملکرد قابل‌اندازه‌گیری وجود خواهد داشت. اگر برنامه شما محدود به CPU باشد، این امر به‌دلیل افزایش استفاده از GPU توسط Android Compositor که معمولاً با فرکانس تقویت‌شده اجرا می‌شود، به یک مشکل مصرف انرژی تبدیل می‌شود. اگر برنامه شما محدود به GPU باشد، «ترکیب‌کننده Android» می‌تواند کار GPU برنامه شما را نیز پیش‌دستی کند و باعث افت عملکرد بیشتر شود.

هنگام اجرای عناوین ارسال در Pixel 4XL، دیده‌ایم که SurfaceFlinger (تکلیف با اولویت بالاتر که Android Compositor را هدایت می‌کند):

  • به‌طور منظم کار برنامه را پیش‌دستی می‌کند و باعث می‌شود زمان‌های فریم ۱ تا ۳ میلی‌ثانیه تحت تأثیر قرار بگیرند، و

  • فشار بیشتری بر حافظه بافت/رأس واحد پردازش گرافیکی وارد می‌کند، زیرا «ترکیب‌کننده» باید کل میان‌گیر قاب را بخواند تا کار ترکیب‌بندی را انجام دهد.

مدیریت صحیح جهت‌یابی تقریباً به‌طور کامل از پیش‌دستی GPU توسط SurfaceFlinger جلوگیری می‌کند، درحالی‌که فرکانس GPU تا ۴۰٪ کاهش می‌یابد زیرا فرکانس تقویت‌شده‌ای که توسط Android Compositor استفاده می‌شود دیگر موردنیاز نیست.

برای اطمینان از اینکه چرخش‌های سطح به‌درستی و با کمترین سربار ممکن انجام می‌شود، همان‌طور که در مورد قبلی دیده شد، باید روش ۳ را پیاده‌سازی کنید. این حالت به‌عنوان پیش‌چرخش شناخته می‌شود. این به سیستم‌عامل Android می‌گوید که برنامه شما چرخش سطح را مدیریت می‌کند. می‌توانید این کار را با ارسال پرچم‌های تبدیل سطح که جهت را درطول ایجاد swapchain مشخص می‌کنند انجام دهید. این کار مانع از این می‌شود که «ترکیب‌کننده Android» چرخش را خودش انجام دهد.

دانستن نحوه تنظیم پرچم تبدیل سطح برای هر برنامه Vulkan مهم است. برنامه‌ها معمولاً یا از چند جهت پشتیبانی می‌کنند یا از یک جهت پشتیبانی می‌کنند که در آن سطح رندر در جهتی متفاوت با جهتی است که دستگاه به‌عنوان جهت هویتی خود درنظر می‌گیرد. برای مثال، برنامه‌ای که فقط در حالت افقی اجرا می‌شود در تلفنی که هویت عمودی دارد، یا برنامه‌ای که فقط در حالت عمودی اجرا می‌شود در رایانه لوحی‌ای که هویت افقی دارد.

اصلاح AndroidManifest.xml

برای مدیریت چرخش دستگاه در برنامه‌تان، ابتدا فایل AndroidManifest.xml برنامه را تغییر دهید تا به Android بگویید که برنامه‌تان تغییرات جهت و اندازه صفحه‌نمایش را مدیریت خواهد کرد. این کار از تخریب و بازآفرینی Activity Android توسط Android و فراخوانی تابع onDestroy() در سطح پنجره موجود هنگام تغییر جهت جلوگیری می‌کند. این کار با افزودن مشخصه‌های orientation (برای پشتیبانی از سطح میانای برنامه‌سازی کاربردی <۱۳) و screenSize به بخش configChanges فعالیت انجام می‌شود:

<activity android:name="android.app.NativeActivity"
          android:configChanges="orientation|screenSize">

اگر برنامه شما جهت صفحه‌نمایش را بااستفاده از مشخصه screenOrientation ثابت می‌کند، لازم نیست این کار را انجام دهید. همچنین، اگر برنامه شما از جهت‌یابی ثابت استفاده می‌کند، فقط باید یک‌بار در هنگام راه‌اندازی/ازسرگیری برنامه، swapchain را راه‌اندازی کند.

دریافت وضوح صفحه‌نمایش هویت و پارامترهای دوربین

سپس، وضوح صفحه‌نمایش دستگاه مرتبط با مقدار 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 نسخه ۱۰ و بالاتر)

مطمئن‌ترین راه برای تشخیص تغییر جهت در برنامه شما این است که بررسی کنید آیا تابع vkQueuePresentKHR() مقدار VK_SUBOPTIMAL_KHR را برمی‌گرداند یا نه. برای مثال:

auto res = vkQueuePresentKHR(queue_, &present_info);
if (res == VK_SUBOPTIMAL_KHR){
  orientationChanged = true;
}

توجه: این راه‌حل فقط در دستگاه‌های دارای Android نسخه ۱۰ و بالاتر کار می‌کند. این نسخه‌های 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() بین ۱۲۰- تا ۲۵۰- میلی‌ثانیه طول کشید و در Pixel 1XL با Android 8، نظرسنجی ۱۱۰- تا ۳۵۰- میلی‌ثانیه طول کشید.

استفاده از پاسخ تماس

گزینه دوم برای دستگاه‌هایی که نسخه‌های قدیمی‌تر از Android 10 را اجرا می‌کنند، ثبت یک onNativeWindowResized() callback برای فراخوانی تابعی است که پرچم orientationChanged را تنظیم می‌کند و به برنامه اطلاع می‌دهد که تغییر جهت رخ داده است:

void android_main(struct android_app *app) {
  ...
  app->activity->callbacks->onNativeWindowResized = ResizeCallback;
}

که در آن ResizeCallback به‌صورت زیر تعریف می‌شود:

void ResizeCallback(ANativeActivity *activity, ANativeWindow *window){
  orientationChanged = true;
}

مشکل این راه‌حل این است که onNativeWindowResized() فقط برای تغییرات جهت ۹۰ درجه فراخوانی می‌شود، مانند رفتن از افقی به عمودی یا برعکس. تغییرات دیگر جهت باعث بازسازی زنجیره تعویض نخواهد شد. برای مثال، تغییر از افقی به افقی معکوس آن را راه‌اندازی نمی‌کند و ترکیب‌کننده Android باید چرخش را برای برنامه شما انجام دهد.

مدیریت تغییر جهت

برای مدیریت تغییر جهت، روال تغییر جهت را در بالای حلقه پرداز اصلی وقتی متغیر orientationChanged روی درست تنظیم شده است فراخوانی کنید. برای مثال:

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

شما تمام کارهای لازم برای بازآفرینی زنجیره تعویض را در تابع OnOrientationChange() انجام می‌دهید. این یعنی:

  1. هرگونه نمونه موجود از Framebuffer و ImageView را ازبین ببرید،

  2. زنجیره مبادله را هنگام ازبین بردن زنجیره مبادله قدیمی (که در ادامه بحث خواهد شد) دوباره ایجاد کنید، و

  3. Framebuffers را با 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 را به سایه‌زن قطعه منتقل کند (مانند عدد صحیحی که جهت فعلی دستگاه را نشان می‌دهد) و از آن برای نگاشت صحیح محاسبات مشتق استفاده کند:

  • برای قاب ازپیش چرخانده‌شده ۹۰ درجه
    • dFdx باید به dFdy نگاشت شود
    • dFdy باید به -dFdx نگاشت شود
  • برای قاب ازپیش چرخیده ۲۷۰ درجه
    • dFdx باید به -dFdy نگاشت شود
    • dFdy باید به dFdx نگاشت شود
  • برای قاب ازپیش چرخیده ۱۸۰ درجه،
    • dFdx باید به -dFdx نگاشت شود
    • dFdy باید به -dFdy نگاشت شود

نتیجه‌گیری

برای اینکه برنامه‌تان بیشترین بهره را از Vulkan در Android ببرد، پیاده‌سازی پیش‌چرخش الزامی است. مهم‌ترین نکات کلیدی این مقاله عبارت‌اند از:

  • مطمئن شوید که درطول ایجاد یا بازایجاد زنجیره تعویض، پرچم پیش‌تبدیل به‌گونه‌ای تنظیم شده باشد که با پرچم برگردانده‌شده توسط سیستم‌عامل Android مطابقت داشته باشد. این کار از سربار ترکیب‌کننده جلوگیری می‌کند.
  • اندازه زنجیره تعویض را روی وضوح هویت سطح پنجره برنامه در جهت طبیعی نمایشگر ثابت نگه دارید.
  • ماتریس MVP را در فضای کلیپ بچرخانید تا جهت دستگاه‌ها را درنظر بگیرید، زیرا وضوح/وسعت زنجیره تعویض دیگر با جهت نمایشگر به‌روز نمی‌شود.
  • مستطیل‌های قیچی و نمای دید را طبق نیاز برنامه‌تان به‌روز کنید.

برنامه نمونه: پیش‌چرخش Android حداقل