이 문서에서는 사전 회전을 구현하여 Vulkan 애플리케이션의 기기 회전을 효율적으로 처리하는 방법을 설명합니다.
Vulkan을 사용하면 OpenGL을 사용할 때보다 렌더링 상태에 관한 훨씬 더 많은 정보를 지정할 수 있습니다. Vulkan을 사용하면 기기 방향 및 렌더링 노출 영역 방향과의 관계와 같이 OpenGL의 드라이버에서 처리하는 항목을 명시적으로 구현해야 합니다. Android에서 기기의 렌더링 노출 영역을 기기 방향과 일치시키는 방법에는 세 가지가 있습니다.
- Android OS는 기기의 디스플레이 처리 장치 (DPU)를 사용할 수 있으며, 이는 하드웨어에서 표면 회전을 효율적으로 처리할 수 있습니다. 지원되는 기기에서만 사용할 수 있습니다.
- Android OS는 컴포지터 패스를 추가하여 표면 회전을 처리할 수 있습니다. 이는 컴포지터가 출력 이미지 회전을 처리하는 방식에 따라 성능 비용이 발생합니다.
- 애플리케이션 자체는 디스플레이의 현재 방향과 일치하는 렌더링 노출 영역에 회전된 이미지를 렌더링하여 노출 영역 회전을 처리할 수 있습니다.
다음 중 어떤 방법을 사용해야 할까요?
현재 애플리케이션은 애플리케이션 외부에서 처리되는 노출 영역 회전이 무료인지 알 수 없습니다. 이를 처리하기 위한 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 애플리케이션에 중요합니다. 애플리케이션은 여러 방향을 지원하거나, 렌더링 노출 영역이 기기에서 ID 방향으로 간주하는 방향과 다른 단일 방향을 지원하는 경향이 있습니다. 예를 들어 세로 모드 휴대전화에서 가로 모드 전용 애플리케이션이나 가로 모드 태블릿의 세로 모드 전용 애플리케이션은 사용할 수 없습니다.
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 속성을 사용하여 화면 방향을 고정하는 경우 이 작업을 실행할 필요가 없습니다. 또한 애플리케이션이 고정 방향을 사용하는 경우 애플리케이션 시작/재개 시 스왑체인을 한 번만 설정하면 됩니다.
ID 화면 해상도 및 카메라 매개변수 가져오기
그런 다음 VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR 값과 연결된 기기의 화면 해상도를 감지합니다. 이 해결 방법은 기기의 기본 방향에 연결되므로 swapchain이 항상 설정되어야 합니다. 이를 가져오는 가장 안정적인 방법은 애플리케이션 시작 시 vkGetPhysicalDeviceSurfaceCapabilitiesKHR()를 호출하고 반환된 범위를 저장하는 것입니다. ID 화면 해상도를 저장하도록 반환된 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를 반환합니다. 이 검사 결과를 애플리케이션의 기본 렌더링 루프에서 액세스할 수 있는 boolean인 orientationChanged에 저장합니다.
기기 방향 변경 감지 (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 compositor가 애플리케이션을 뒤집어야 합니다.
방향 변경 처리
방향 변경을 처리하려면 orientationChanged 변수가 true로 설정되었을 때 기본 렌더링 루프 상단에서 방향 변경 루틴을 호출합니다. 예를 들면 다음과 같습니다.
bool VulkanDrawFrame() {
if (orientationChanged) {
OnOrientationChange();
}
OnOrientationChange() 함수 내에서 스왑체인을 다시 만드는 데 필요한 모든 작업을 실행합니다. 이는 다음을 의미합니다.
Framebuffer및ImageView의 기존 인스턴스를 모두 폐기합니다.이전 스왑체인을 폐기하면서 스왑체인을 다시 만듭니다 (다음에 설명).
새 스왑체인의 DisplayImages로 Framebuffers를 다시 만듭니다. 참고: 연결 이미지 (예: 깊이/스텐실 이미지)는 사전 회전된 스왑체인 이미지의 ID 해상도를 기반으로 하므로 일반적으로 다시 만들 필요가 없습니다.
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);
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의 표시(예: 현재 기기 방향을 나타내는 정수)를 프래그먼트 셰이더에 전달하고 이를 사용하여 파생 계산을 올바르게 매핑해야 합니다.
- 90도 사전 회전된 프레임
- dFdx는 dFdy에 매핑되어야 합니다.
- dFdy는 -dFdx에 매핑되어야 합니다.
- 270도 사전 회전된 프레임
- dFdx는 -dFdy에 매핑되어야 합니다.
- dFdy는 dFdx에 매핑되어야 합니다.
- 180도 사전 회전된 프레임
- dFdx는 -dFdx에 매핑되어야 합니다.
- dFdy는 -dFdy에 매핑되어야 합니다.
결론
Android에서 애플리케이션이 Vulkan을 최대한 활용하려면 사전 회전을 구현해야 합니다. 이 도움말에서 가장 중요한 점은 다음과 같습니다.
- 스왑체인 생성 또는 재생성 중에 사전 변환 플래그가 Android 운영체제에서 반환된 플래그와 일치하도록 설정되어 있는지 확인합니다. 이렇게 하면 compositor 오버헤드를 방지할 수 있습니다.
- 디스플레이의 기본 방향에서 앱 창 표면의 ID 해상도로 스왑체인 크기를 고정합니다.
- 스왑체인 해상도/범위가 더 이상 디스플레이 방향에 따라 업데이트되지 않으므로 기기 방향을 고려하여 클립 공간에서 MVP 행렬을 회전합니다.
- 애플리케이션에 필요한 대로 표시 영역 및 가위 사각형을 업데이트합니다.