Vulkan 사전 회전으로 기기 방향 처리

이 문서에서는 사전 회전을 구현하여 Vulkan 애플리케이션의 기기 회전을 효율적으로 처리하는 방법을 설명합니다.

Vulkan을 사용하면 OpenGL보다 렌더링 상태에 관한 더욱 자세한 정보를 지정할 수 있습니다. Vulkan에서는 기기 방향렌더링 노출 영역 방향과의 관계와 같이 OpenGL에서 드라이버가 처리하는 작업을 명시적으로 구현해야 합니다. Android에서 기기 방향으로 기기의 렌더링 노출 영역을 조정하는 데 사용할 수 있는 세 가지 방법이 있습니다.

  1. Android OS는 하드웨어의 노출 영역 회전을 효율적으로 처리할 수 있는 기기의 디스플레이 처리 장치 (DPU)를 사용할 수 있습니다. 지원되는 기기에서만 사용할 수 있습니다.
  2. Android OS는 컴포지터 패스를 추가하여 표시 영역 회전을 처리할 수 있습니다. 컴포지터가 출력 이미지 회전을 처리하는 방법에 따라 성능 비용이 발생합니다.
  3. 애플리케이션 자체는 디스플레이의 현재 방향과 일치하는 렌더링 노출 영역에 회전된 이미지를 렌더링하여 노출 영역 회전을 처리할 수 있습니다.

다음 중 어떤 방법을 사용해야 하나요?

현재 애플리케이션 외부에서 처리된 노출 영역 회전이 무료인지 유료인지 여부는 애플리케이션에서 알 방법이 없습니다. 이를 처리하기 위한 DPU가 있더라도 측정 가능한 성능에 불이익이 있을 가능성이 있습니다. 애플리케이션이 CPU에 종속되면 (일반적으로 부스트 주파수에서 실행 중) Android Compositor의 GPU 사용량 증가로 인해 전력 문제가 발생합니다. 애플리케이션이 GPU에 종속되면 Android Compositor는 애플리케이션의 GPU 작업을 선점하여 추가 성능 손실이 발생할 수도 있습니다.

Pixel 4XL에서 출시 타이틀을 실행할 때 SurfaceFlinger (Android 컴포저를 실행하는 우선순위가 더 높은 작업)가 다음과 같이 표시되었습니다.

  • 애플리케이션 작업을 정기적으로 선점하여 프레임 시간에 1~3ms의 적중을 일으킵니다.

  • 컴포저이터가 컴포지션 작업을 실행하려면 전체 프레임버퍼를 읽어야 하므로 GPU의 정점/텍스처 메모리에 부담이 가중됩니다.

방향을 제대로 처리하면 SurfaceFlinger에 의한 GPU 선점이 거의 중지되는 반면, Android Compositor에서 사용하는 부스트 주파수는 더 이상 필요하지 않으므로 GPU 주파수는 40% 감소합니다.

위 예와 같이 가급적 적은 오버헤드로 표시 영역 회전을 올바르게 처리하려면 메서드 3을 구현해야 합니다. 이를 사전 순환이라고 합니다. 그러면 Android OS에서 이 노출 영역 회전을 처리한다는 사실을 알 수 있습니다. 또한 swapchain 생성 중에 방향을 지정하는 표시 영역 변환 플래그를 전달하여 수행할 수 있습니다. 그러면 Android Compositor가 회전 자체를 수행하지 않습니다.

표시 영역 변환 플래그를 설정하는 방법을 아는 것은 모든 Vulkan 애플리케이션에 있어 중요합니다. 애플리케이션은 여러 방향을 지원하거나 렌더링 노출 영역이 기기에서 올바른 방향이라 생각하는 방향과 다른 단일 방향을 지원하는 경향이 있습니다. 예를 들어 세로 모드 휴대전화에서 가로 모드 전용 애플리케이션이나 가로 모드 태블릿의 세로 모드 전용 애플리케이션은 사용할 수 없습니다.

AndroidManifest.xml 수정

앱에서 기기 회전을 처리하려면 먼저 앱의 AndroidManifest.xml 파일을 변경하여 Android에 앱이 방향과 화면 크기 변경을 처리한다고 알립니다. 이렇게 하면 Android가 Android Activity를 삭제 및 재생성하고 방향 변경이 발생할 때 기존 창 표시 영역에서 onDestroy() 함수를 호출하지 못하도록 합니다. orientation(API 수준 13 미만 지원) 및 screenSize 속성을 활동의 configChanges 섹션에 추가하여 이 작업을 수행합니다.

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

애플리케이션에서 screenOrientation 속성을 사용하여 화면 방향을 수정하는 경우 이 작업을 할 필요가 없습니다. 또한 애플리케이션이 고정 방향을 사용하는 경우에는 애플리케이션 시작/재개 시 swapchain을 한 번만 설정하면 됩니다.

ID 화면 해상도 및 카메라 매개변수 가져오기

다음으로 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는 vkQueuePresentKHR()에서 VK_SUBOPTIMAL_KHR를 반환합니다. 이 체크인 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;
}

Android 10을 실행하는 Pixel 4에서 vkGetPhysicalDeviceSurfaceCapabilitiesKHR() 폴링은 0.120~0.250ms 사이에 발생했으며, Android 8을 실행하는 Pixel 1XL에서 폴링은 0.110~0.350ms가 걸렸습니다.

콜백 사용

Android 10 이하를 실행하는 기기에서 사용할 두 번째 옵션은 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() 함수 내에서 swapchain을 다시 만드는 데 필요한 모든 작업을 실행합니다. 즉, 다음과 같은 작업이 가능합니다.

  1. FramebufferImageView의 기존 인스턴스를 모두 소멸합니다.

  2. swapchain 재생성, 기존 swapchain 삭제 (이후에 설명)

  3. 새 swapchain의 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로 재설정하여 방향 변경을 처리했음을 표시합니다.

Swapchain 재생성

이전 섹션에서 swapchain을 다시 만들어야 한다고 언급했습니다. 이를 위한 첫 번째 단계는 렌더링 표시 영역의 새로운 특성을 얻는 것입니다.

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

VkSurfaceCapabilities 구조에 새 정보가 채워져 있으면 이제 currentTransform 필드를 확인하여 방향 변경이 발생했는지 확인할 수 있습니다. 나중에 MVP 매트릭스를 조정할 때 필요하므로 pretransformFlag 필드에 나중에 저장합니다.

이렇게 하려면 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 필드는 surfaceCapabilities의 currentTransform 필드로 설정된 pretransformFlag 변수로 채워집니다. 또한 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;

고려 사항 - 전체 화면이 아닌 표시 영역 및 Scissor

애플리케이션이 전체 화면이 아닌 표시 영역/Scissor 영역을 사용 중인 경우 기기의 방향에 따라 이 영역을 업데이트해야 합니다. 이를 위해서는 Vulkan 파이프라인 생성 중에 동적 표시 영역 및 Scissor 옵션을 사용 설정해야 합니다.

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

xy 변수는 표시 영역의 왼쪽 상단 모서리의 좌표를 정의하고, wh 변수는 각 표시 영역의 너비와 높이를 정의합니다. 동일한 계산을 사용하여 Scissor 테스트를 설정할 수도 있으며, 완전성을 위해 여기에 포함되어 있습니다.

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

고려 사항 - 프래그먼트 셰이더 파생

애플리케이션이 dFdxdFdy와 같은 파생 계산을 사용하는 경우, 이러한 계산은 픽셀 공간에서 실행되므로 회전된 좌표계를 고려하여 추가 변환이 필요할 수 있습니다. 이 경우 앱에서 preTransform의 표시(예: 현재 기기 방향을 나타내는 정수)를 프래그먼트 셰이더에 전달하고 이를 사용하여 파생 계산을 올바르게 매핑해야 합니다.

  • 90도 사전 회전된 프레임
    • dFdxdFdy에 매핑해야 함
    • dFdy-dFdx에 매핑해야 함
  • 270도 사전 회전된 프레임
    • dFdx-dFdy에 매핑해야 함
    • dFdydFdx에 매핑해야 함
  • 180도 사전 회전된 프레임
    • dFdx-dFdx에 매핑해야 함
    • dFdy-dFdy에 매핑해야 함

결론

Android에서 애플리케이션이 Vulkan을 최대한 활용하려면 사전 회전을 구현해야 합니다. 이 도움말에서 가장 중요한 점은 다음과 같습니다.

  • swapchain 생성 또는 재생성 중에 사전 변환 플래그가 Android 운영체제에서 반환된 플래그와 일치하도록 설정되어 있는지 확인합니다. 그러면 컴포지터 오버헤드가 방지됩니다.
  • swapchain 크기는 디스플레이의 자연스러운 방향으로 앱의 창 표시 영역의 기본 해상도로 고정됩니다.
  • swapchain 해상도/범위가 디스플레이의 방향으로 더 이상 업데이트되지 않으므로 기기 방향을 고려하여 클립 공간의 MVP 매트릭스를 회전합니다.
  • 애플리케이션에서 필요에 따라 표시 영역 및 Scissor 직사각형을 업데이트합니다.

샘플 앱: 최소한의 Android 사전 회전