جهت گیری دستگاه را با پیش چرخش Vulkan کنترل کنید

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

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

  1. سیستم عامل اندروید می‌تواند از واحد پردازش نمایشگر (DPU) دستگاه استفاده کند، که می‌تواند چرخش سطح را به صورت سخت‌افزاری به طور کارآمد مدیریت کند. فقط در دستگاه‌های پشتیبانی‌شده موجود است.
  2. سیستم عامل اندروید می‌تواند چرخش سطح را با اضافه کردن یک مسیر کامپوزیتور (compositor pass) مدیریت کند. این کار بسته به نحوه‌ی برخورد کامپوزیتور با چرخش تصویر خروجی، هزینه‌ی عملکردی خواهد داشت.
  3. خودِ برنامه می‌تواند چرخش سطح را با رندر کردن یک تصویر چرخانده شده روی یک سطح رندر که با جهت فعلی نمایشگر مطابقت دارد، مدیریت کند.

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

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

هنگام اجرای بازی‌های عرضه اولیه روی پیکسل ۴XL، شاهد آن SurfaceFlinger (وظیفه با اولویت بالاتر که Android Compositor را هدایت می‌کند) بوده‌ایم:

  • مرتباً کار برنامه را متوقف می‌کند و باعث می‌شود زمان فریم‌ها ۱ تا ۳ میلی‌ثانیه کاهش یابد، و

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

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

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

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

تغییر AndroidManifest.xml

برای مدیریت چرخش دستگاه در برنامه خود، با تغییر فایل AndroidManifest.xml برنامه شروع کنید تا به اندروید بگویید که برنامه شما تغییرات جهت و اندازه صفحه را مدیریت خواهد کرد. این کار مانع از آن می‌شود که اندروید، Activity اندروید را از بین ببرد و دوباره ایجاد کند و هنگام تغییر جهت، تابع onDestroy() را روی سطح پنجره موجود فراخوانی کند. این کار با اضافه کردن ویژگی‌های orientation (برای پشتیبانی از سطح API <13) و 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 است که ما برای ذخیره وضوح هویت مذکور از سطح پنجره برنامه در جهت طبیعی صفحه نمایش استفاده می‌کنیم.

تشخیص تغییرات جهت دستگاه (اندروید ۱۰+)

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

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

توجه: این راه‌حل فقط روی دستگاه‌هایی که اندروید ۱۰ و بالاتر را اجرا می‌کنند، کار می‌کند. این نسخه‌های اندروید، VK_SUBOPTIMAL_KHR از vkQueuePresentKHR() برمی‌گردانند. ما نتیجه این بررسی را در orientationChanged ذخیره می‌کنیم، یک boolean که از حلقه رندر اصلی برنامه‌ها قابل دسترسی است.

تشخیص تغییرات جهت دستگاه (قبل از اندروید ۱۰)

برای دستگاه‌هایی که اندروید ۱۰ یا بالاتر دارند، پیاده‌سازی متفاوتی مورد نیاز است، زیرا VK_SUBOPTIMAL_KHR پشتیبانی نمی‌شود.

استفاده از نظرسنجی

در دستگاه‌های قبل از اندروید ۱۰، می‌توانید تبدیل دستگاه فعلی را در هر فریم pollingInterval بررسی کنید، که در آن pollingInterval یک نوع جزئیات است که توسط برنامه‌نویس تعیین می‌شود. روش انجام این کار با فراخوانی vkGetPhysicalDeviceSurfaceCapabilitiesKHR() و سپس مقایسه فیلد currentTransform برگردانده شده با فیلد تبدیل سطح ذخیره شده فعلی (در این مثال کد که در pretransformFlag ذخیره شده است) است.

currFrameCount++;
if (currFrameCount >= pollInterval){
  VkSurfaceCapabilitiesKHR capabilities;
  vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);

  if (pretransformFlag != capabilities.currentTransform) {
    window_resized = true;
  }
  currFrameCount = 0;
}

در گوشی پیکسل ۴ که اندروید ۱۰ روی آن نصب است، زمان اجرای تابع vkGetPhysicalDeviceSurfaceCapabilitiesKHR() بین ۰.۱۲۰ تا ۰.۲۵۰ میلی‌ثانیه و در گوشی پیکسل ۱XL که اندروید ۸ روی آن نصب است، ۰.۱۱۰ تا ۰.۳۵۰ میلی‌ثانیه طول کشید.

استفاده از 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() فقط برای تغییرات جهت‌گیری ۹۰ درجه‌ای، مانند تغییر از حالت افقی به عمودی یا برعکس، فراخوانی می‌شود. سایر تغییرات جهت‌گیری، بازسازی swapchain را آغاز نمی‌کنند. برای مثال، تغییر از حالت افقی به حالت معکوس، آن را آغاز نمی‌کند و نیاز است که کامپوزیتور اندروید این عمل را برای برنامه شما انجام دهد.

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

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

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

شما تمام کارهای لازم برای ایجاد مجدد swapchain را در تابع OnOrientationChange() انجام می‌دهید. این بدان معناست که شما:

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

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

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

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

نتیجه‌گیری

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

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

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