Xử lý hướng thiết bị bằng chế độ xoay trước trong Vulkan

Bài viết này mô tả cách xử lý hiệu quả việc xoay thiết bị trong ứng dụng Vulkan bằng cách triển khai chế độ xoay trước.

Vulkan cho phép bạn chỉ định nhiều thông tin về trạng thái kết xuất hơn so với OpenGL. Quyền lực đó đi kèm với một số trách nhiệm mới: bạn cần triển khai rõ ràng những việc mà trình điều khiển đã xử lý trong OpenGL. Một trong số đó là hướng thiết bị và mối quan hệ với hướng giao diện (surface) kết xuất. Hiện tại, có 3 cách để Android xử lý việc điều chỉnh giao diện kết xuất của thiết bị bằng hướng thiết bị:

  1. Thiết bị có Bộ xử lý hiển thị (DPU) có thể xử lý hiệu quả việc xoay giao diện trong phần cứng. (chỉ trên thiết bị được hỗ trợ)
  2. Hệ điều hành Android có thể xử lý việc xoay giao diện bằng cách thêm nội dung truyền đến trình tổng hợp, sẽ có chi phí hiệu suất tuỳ thuộc vào cách trình tổng hợp phải xử lý việc xoay hình ảnh đầu ra.
  3. Bản thân ứng dụng có thể xử lý việc xoay giao diện bằng cách kết xuất hình ảnh xoay lên giao diện kết xuất khớp với hướng hiện tại của màn hình.

Điều này có ý nghĩa gì đối với ứng dụng?

Hiện không có cách nào để ứng dụng biết được việc xoay giao diện được xử lý bên ngoài ứng dụng gây hao tổn hay không. Ngay cả khi có DPU để xử lý việc này, thì bạn vẫn có thể phải chịu mức hao tổn hiệu suất có thể đo lường. Nếu ứng dụng bị ràng buộc bởi CPU, thì đây sẽ trở thành vấn đề về điện năng do Trình tổng hợp Android (thường chạy ở tần suất tăng cường) làm tăng mức sử dụng GPU. Nếu ứng dụng bị ràng buộc bởi GPU, thì Trình tổng hợp Android cũng có thể giành quyền hoạt động của GPU trong ứng dụng diễn ra và làm tăng hiện tượng mất hiệu suất bổ sung.

Khi chạy tiêu đề vận chuyển trên Pixel 4XL, chúng tôi thấy rằng SurfaceFlinger (tác vụ có mức độ ưu tiên cao hơn hỗ trợ Trình tổng hợp Android) thường xuyên giành quyền hoạt động của ứng dụng, dẫn đến thời gian kết xuất khung hình bị ảnh hưởng từ 1–3 mili giây, cũng như tăng sức ép lên bộ nhớ kết cấu/đỉnh của GPU vì Trình tổng hợp phải đọc toàn bộ vùng đệm khung để tổng hợp.

Việc xử lý hướng đúng cách sẽ gần như ngăn chặn hoàn toàn khả năng SurfaceFlinger giành quyền của GPU, trong khi tần suất GPU giảm 40% vì Trình tổng hợp Android không còn đòi hỏi tần suất tăng cường nữa.

Để đảm bảo việc xoay giao diện được xử lý đúng cách với ít hao tổn nhất có thể (như trong trường hợp trên), bạn nên triển khai phương thức 3, được gọi là xoay trước. Điều này cho hệ điều hành Android biết rằng ứng dụng của bạn xử lý việc xoay giao diện. Bạn có thể làm như vậy bằng cách truyền cờ biến đổi giao diện chỉ định hướng trong khi tạo chuỗi hoán đổi (swapchain). Điều này ngăn Trình tổng hợp Android tự thực hiện việc xoay.

Việc biết cách đặt cờ biến đổi giao diện là rất quan trọng đối với mọi ứng dụng Vulkan, vì các ứng dụng có xu hướng hỗ trợ nhiều hướng hoặc hỗ trợ một hướng duy nhất mà giao diện kết xuất ở một hướng khác với hướng mà thiết bị coi là hướng gốc. Ví dụ: ứng dụng chỉ hiển thị theo chế độ ngang trên điện thoại có hướng gốc là hướng dọc hoặc ứng dụng chỉ hiển thị theo chế độ dọc trên máy tính bảng có hướng gốc là hướng ngang.

Trong bài viết này, chúng tôi sẽ mô tả chi tiết cách triển khai chế độ xoay trước và xử lý việc xoay thiết bị trong ứng dụng Vulkan.

Sửa đổi AndroidManifest.xml

Để xử lý việc xoay thiết bị trong ứng dụng, hãy bắt đầu bằng cách thay đổi tệp AndroidManifest.xml của ứng dụng để cho Android biết rằng ứng dụng của bạn sẽ xử lý các thay đổi về hướng và kích thước màn hình. Điều này ngăn Android huỷ bỏ và tạo lại Android Activity cũng như gọi hàm onDestroy() trên giao diện cửa sổ hiện có khi có sự thay đổi về hướng. Bạn có thể thực hiện việc này bằng cách thêm thuộc tính orientation (để hỗ trợ API cấp 12 trở xuống) và screenSize vào mục configChanges của hoạt động:

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

Nếu ứng dụng của bạn sửa hướng màn hình bằng thuộc tính screenOrientation, thì bạn không cần làm việc này. Ngoài ra, nếu ứng dụng của bạn sử dụng hướng cố định thì ứng dụng sẽ chỉ cần thiết lập chuỗi hoán đổi một lần khi khởi động/tiếp tục ứng dụng.

Nhận độ phân giải nhận dạng của màn hình và thông số máy ảnh

Việc tiếp theo mà bạn cần làm là phát hiện độ phân giải màn hình của thiết bị, được liên kết với giá trị VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. Độ phân giải này được liên kết với hướng gốc của thiết bị và do đó, là độ phân giải mà bạn luôn phải đặt cho chuỗi hoán đổi. Cách đáng tin cậy nhất để nhận được độ phân giải này là thực hiện lệnh gọi đến vkGetPhysicalDeviceSurfaceCapabilitiesKHR() khi khởi động ứng dụng và lưu trữ số liệu kích thước được trả về. Bạn nên hoán đổi chiều rộng và chiều cao dựa trên currentTransform (cũng nằm trong kết quả trả về) để đảm bảo rằng bạn đang lưu trữ độ phân giải màn hình chính thức:

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 là cấu trúc VkExtent2D mà chúng tôi sử dụng để lưu trữ độ phân giải nhận dạng nêu trên của giao diện cửa sổ ứng dụng theo hướng tự nhiên của màn hình.

Phát hiện các thay đổi về hướng thiết bị (Android 10 trở lên)

Cách đáng tin cậy nhất để phát hiện sự thay đổi về hướng trong ứng dụng là xác minh xem hàm vkQueuePresentKHR() có trả về VK_SUBOPTIMAL_KHR hay không. Ví dụ:

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

Phát hiện các thay đổi về hướng thiết bị (Android 9 trở xuống)

Đối với các thiết bị chạy phiên bản Android 9 trở xuống, bạn cần triển khai theo cách khác vì VK_SUBOPTIMAL_KHR không được hỗ trợ.

Sử dụng tính năng kiểm tra vòng

Trên các thiết bị chạy Android 9 trở xuống, bạn có thể kiểm tra vòng sự biến đổi hiện tại của thiết bị sau mỗi pollingInterval khung, trong đó pollingInterval là độ chi tiết do lập trình viên quyết định. Bạn có thể thực hiện việc này bằng cách gọi vkGetPhysicalDeviceSurfaceCapabilitiesKHR(), sau đó so sánh trường currentTransform được trả về với trường trong hoạt động biến đổi giao diện hiện đang được lưu trữ (trong mã ví dụ này thì được lưu trữ trong pretransformFlag).

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

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

Trên Pixel 4 chạy Android 10, việc kiểm tra vòng vkGetPhysicalDeviceSurfaceCapabilitiesKHR() mất 0,120 – 0,250 mili giây còn trên Pixel 1XL chạy Android 8, việc kiểm tra vòng sẽ mất 0,110 – 0,350 mili giây.

Sử dụng các lệnh gọi lại

Tuỳ chọn thứ hai cho các thiết bị chạy Android 9 trở xuống là đăng ký lệnh gọi lại onNativeWindowResized() để gọi một hàm đặt cờ orientationChanged, báo hiệu cho ứng dụng rằng có sự thay đổi về hướng:

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

Trong đó, ResizeCallback được định nghĩa là:

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

Hạn chế của giải pháp này là onNativeWindowResized() chỉ có thể được gọi khi hướng thay đổi 90 độ (từ ngang sang dọc hoặc ngược lại). Vì vậy, ví dụ như việc thay đổi hướng từ ngang sang ngang lộn ngược sẽ không kích hoạt việc tạo lại chuỗi hoán đổi, đòi hỏi trình tổng hợp Android phải thực hiện việc lật cho ứng dụng của bạn.

Xử lý việc thay đổi hướng

Để xử lý việc thay đổi hướng, hãy gọi quy trình thay đổi hướng ở đầu vòng lặp kết xuất chính khi biến orientationChanged được đặt thành true. Ví dụ:

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

Trong hàm OnOrientationChange(), bạn sẽ thực hiện mọi việc cần thiết để tạo lại chuỗi hoán đổi. Thao tác này sẽ huỷ bỏ mọi bản sao hiện có của FramebufferImageView; tạo lại chuỗi hoán đổi trong khi huỷ bỏ chuỗi hoán đổi cũ (sẽ được thảo luận tiếp theo); và sau đó tạo lại Vùng đệm khung hình bằng DisplayImages (Hình ảnh hiển thị) của chuỗi hoán đổi mới. Lưu ý rằng hình ảnh đính kèm (ví dụ: hình ảnh chiều sâu/khung tô) thường không cần được tạo lại vì các hình ảnh đó dựa trên độ phân giải nhận dạng của hình ảnh chuỗi hoán đổi được xoay trước.

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;
}

Và ở cuối hàm, bạn đặt lại cờ orientationChanged thành false để cho biết rằng bạn đã xử lý việc thay đổi hướng.

Tạo lại chuỗi hoán đổi

Trong phần trước, chúng tôi đã đề cập đến việc phải tạo lại chuỗi hoán đổi. Bước đầu tiên để thực hiện việc này bao gồm việc tải các đặc điểm mới của giao diện kết xuất:

void createSwapChain(VkSwapchainKHR oldSwapchain) {
   VkSurfaceCapabilitiesKHR capabilities;
   vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);
   pretransformFlag = capabilities.currentTransform;

Với cấu trúc VkSurfaceCapabilities được điền sẵn thông tin mới, giờ đây, bạn có thể kiểm tra xem liệu có sự thay về hướng hay không bằng cách kiểm tra trường currentTransform. Bạn sẽ lưu trữ thông tin này trong trường pretransformFlag, sau này bạn sẽ cần đến thông tin này khi điều chỉnh ma trận MVP.

Để làm vậy, hãy chỉ định các thuộc tính sau trong cấu trúc 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);
}

Trường imageExtent sẽ được điền sẵn số liệu kích thước displaySizeIdentity mà bạn đã lưu trữ khi khởi động ứng dụng. Trường preTransform sẽ được điền sẵn bằng biến pretransformFlag (được đặt cho trường currentTransform của surfaceCapabilities). Bạn cũng đặt trường oldSwapchain thành chuỗi hoán đổi sẽ bị huỷ bỏ.

Điều chỉnh ma trận MVP

Việc cuối cùng cần làm là áp dụng tính năng biến đổi trước bằng cách áp dụng một ma trận xoay cho ma trận MVP của bạn. Về cơ bản, thao tác này thực hiện việc xoay trong không gian cắt (clip) để hình ảnh thu được xoay theo hướng của thiết bị hiện tại. Sau đó, bạn chỉ cần truyền ma trận MVP đã cập nhật này vào chương trình đổ bóng đỉnh và sử dụng như bình thường mà không cần sửa đổi chương trình đổ bóng.

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;

Cân nhắc – Cắt (Scissor) và khung nhìn không toàn màn hình

Nếu ứng dụng của bạn đang sử dụng khu vực cắt/khung nhìn không toàn màn hình, các ứng dụng đó cần được cập nhật theo hướng của thiết bị. Điều này đòi hỏi bạn bật các tuỳ chọn Khung nhìn và Cắt động trong quá trình tạo quy trình của 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);

Cách tính toán thực tế số liệu kích thước khung nhìn trong quá trình ghi vùng đệm lệnh có dạng như sau:

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);

Biến xy xác định toạ độ của góc trên cùng bên trái của khung nhìn, trong khi wh lần lượt xác định chiều rộng và chiều cao của khung nhìn. Bạn cũng có thể sử dụng phương pháp tính toán này để đặt quy trình kiểm thử cắt. Bên dưới là mã hoàn chỉnh:

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);

Cân nhắc – Đạo hàm trong chương trình đổ bóng mảnh

Nếu ứng dụng của bạn đang sử dụng các phép tính đạo hàm như dFdxdFdy, thì bạn có thể cần thêm các phép biến đổi để xét đến hệ thống toạ độ xoay vì các phép tính này được thực thi trong không gian pixel. Điều này đòi hỏi ứng dụng truyền một số chỉ báo của hoạt động biến đổi trước vào chương trình đổ bóng mảnh (chẳng hạn như một số nguyên biểu thị hướng thiết bị hiện tại) và sử dụng số đó để ánh xạ các phép tính đạo hàm đúng cách:

  • Đối với khung xoay trước 90 độ
    • Cần ánh xạ dFdx với dFdy
    • Cần ánh xạ dFdy với -dFdx
  • Đối với khung xoay trước 270 độ
    • Cần ánh xạ dFdx với -dFdy
    • Cần ánh xạ dFdy với dFdx
  • Đối với khung xoay trước 180 độ,
    • Cần ánh xạ dFdx với -dFdx
    • Cần ánh xạ dFdy với -dFdy

Kết luận

Để ứng dụng của bạn tận dụng tối đa Vulkan trên Android, bạn phải triển khai chế độ xoay trước. Những điểm quan trọng nhất mà bạn cần nắm được trong bài viết này là:

  • Đảm bảo rằng trong quá trình tạo hoặc tạo lại chuỗi hoán đổi, cờ biến đổi trước được đặt khớp với cờ do hệ điều hành Android trả về. Việc này sẽ giúp trình tổng hợp không gây ra hao tổn.
  • Cố định kích thước chuỗi hoán đổi theo độ phân giải nhận dạng của giao diện cửa sổ trong ứng dụng theo hướng tự nhiên của màn hình.
  • Xoay ma trận MVP trong không gian cắt để biết hướng của thiết bị vì độ phân giải/số liệu kích thước trong chuỗi hoán đổi không còn cập nhật theo hướng của màn hình.
  • Cập nhật hình chữ nhật cắt và khung nhìn theo yêu cầu của ứng dụng

Ứng dụng mẫu: Chế độ xoay trước tối thiểu trên Android