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.

Với Vulkan, bạn có thể chỉ định nhiều thông tin về trạng thái kết xuất hơn so với OpenGL. Với Vulkan, bạn phải triển khai rõ ràng các thao tác do trình điều khiển xử lý trong OpenGL, chẳng hạn như hướng thiết bị và mối quan hệ của hướng này với hướng giao diện kết xuất. 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ị theo hướng thiết bị:

  1. Hệ điều hành Android có thể sử dụng Bộ xử lý hiển thị (DPU) của thiết bị để xử lý hiệu quả việc xoay giao diện trong phần cứng. Điều này chỉ thực hiện được trên các 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. Điều này sẽ gây hao tổn 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 trên giao diện kết xuất sao cho khớp với hướng hiện tại của màn hình.

Bạn nên sử dụng phương pháp nào bên trên?

Hiện tại, không có cách nào để ứng dụng biết được liệu xử lý việc xoay giao diện bên ngoài ứng dụng có 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ột mức hao tổn hiệu suất (có thể đo lường được). 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) sử dụng nhiều GPU hơn. 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 sử dụng GPU của ứng dụng và làm giảm hiệu suất ứng dụng của bạn hơn nữa.

Khi chạy tiêu đề vận chuyển trên Pixel 4XL, chúng tôi nhận thấy SurfaceFlinger (tác vụ có mức độ ưu tiên cao hơn giúp 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 đến 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 sử dụng 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. Đây là phương thức 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. Ứng dụng thường có xu hướng hoặc hỗ trợ nhiều hướng hoặc hỗ trợ một hướng duy nhất (trong đó hướng của giao diện kết xuất 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.

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 (swapchain) một lần khi khởi động/tiếp tục ứng dụng.

Nhận độ phân giải gốc của màn hình và tham số máy ảnh

Tiếp theo, hãy 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ề. 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 gốc của màn hình:

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 gốc 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;
}

Lưu ý: Giải pháp này chỉ dùng được trên các thiết bị chạy Android 10 trở lên. Các phiên bản Android này sẽ trả về VK_SUBOPTIMAL_KHR từ vkQueuePresentKHR(). Chúng tôi lưu trữ kết quả của quá trình kiểm tra này trong orientationChanged, một boolean có thể truy cập được từ vòng lặp kết xuất chính của ứng dụng.

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

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

Sử dụng tính năng thăm dò (polling)

Trên các thiết bị chạy Android 9 trở xuống, bạn có thể thăm dò 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 đoạn 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 thăm dò vkGetPhysicalDeviceSurfaceCapabilitiesKHR() mất 0,120 – 0,250 mili giây còn trên Pixel 1XL chạy Android 8, việc thăm dò sẽ mất 0,110 – 0,350 mili giây.

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

Lựa 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 gọi khi hướng thay đổi 90 độ, chẳng hạn như từ ngang sang dọc hoặc ngược lại. Những thay đổi khác về hướng sẽ không kích hoạt việc tạo lại chuỗi hoán đổi. Ví dụ như việc thay đổi hướng từ ngang sang ngang lộn ngược (và nằm ngang) 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 đổ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();
}

Bạn sẽ thực hiện mọi việc cần thiết để tạo lại chuỗi hoán đổi trong hàm OnOrientationChange(). Điều này có nghĩa là bạn:

  1. Huỷ bỏ mọi FramebufferImageView hiện có,

  2. 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 trong phần tiếp theo) và

  3. Tạo lại Bộ đệm khung bằng DisplayImage của chuỗi hoán đổi mới. Lưu ý: 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 (swapchain)

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 bạn phải 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. 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 độ
    • Phải ánh xạ dFdx với dFdy
    • Phải ánh xạ dFdy với -dFdx
  • Đối với khung xoay trước 270 độ
    • Phải ánh xạ dFdx với -dFdy
    • Phải ánh xạ dFdy với dFdx
  • Đối với khung xoay trước 180 độ,
    • Phải ánh xạ dFdx với -dFdx
    • Phải á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 sao cho 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