В этой статье рассказывается, как эффективно обрабатывать поворот устройства в приложении Vulkan, реализовав предварительный поворот.
Vulkan позволяет указывать гораздо больше информации о состоянии рендеринга, чем OpenGL. В Vulkan вам нужно явно реализовать то, что обрабатывается драйвером в OpenGL, например ориентацию устройства и ее связь с ориентацией поверхности рендеринга. Android может согласовывать поверхность рендеринга с ориентацией устройства тремя способами:
- ОС Android может использовать блок обработки дисплея (DPU) устройства, который эффективно обрабатывает поворот поверхности на аппаратном уровне. Доступно только на поддерживаемых устройствах.
- ОС Android может обрабатывать поворот поверхности, добавляя проход компоновщика. Это может повлиять на производительность в зависимости от того, как компоновщик обрабатывает поворот выходного изображения.
- Приложение может самостоятельно обрабатывать поворот поверхности, отображая повернутое изображение на поверхности рендеринга, которая соответствует текущей ориентации дисплея.
Какой из этих методов вы бы использовали?
В настоящее время приложение не может определить, будет ли поворот экрана, обрабатываемый за пределами приложения, бесплатным. Даже если у вас есть DPU, который может взять на себя эту задачу, вы все равно, скорее всего, столкнетесь с заметным снижением производительности. Если ваше приложение ограничено по ЦП, это становится проблемой энергопотребления из-за повышенного использования графического процессора Android Compositor, который обычно работает на повышенной частоте. Если ваше приложение ограничено графическим процессором, то Android Compositor может также вытеснить работу графического процессора вашего приложения, что приведет к дополнительной потере производительности.
При запуске игр на Pixel 4XL мы заметили, что SurfaceFlinger (более приоритетная задача, которая управляет Android Compositor):
Регулярно прерывает работу приложения, увеличивая время кадра на 1–3 мс.
Увеличивает нагрузку на память вершин и текстур графического процессора, поскольку компоновщику приходится считывать весь буфер кадров.
Правильная обработка ориентации почти полностью останавливает вытеснение графического процессора SurfaceFlinger, а частота графического процессора падает на 40 %, поскольку повышенная частота, используемая Android Compositor, больше не требуется.
Чтобы повороты экрана обрабатывались правильно и с минимальными затратами ресурсов, как в предыдущем случае, следует реализовать способ 3. Это называется предварительной ротацией. Это сообщает ОС Android, что ваше приложение обрабатывает поворот экрана. Для этого передайте флаги преобразования поверхности, которые указывают ориентацию во время создания цепочки буферов. Это предотвращает поворот, выполняемый самим Android Compositor.
Умение устанавливать флаг преобразования поверхности важно для каждого приложения Vulkan. Приложения обычно поддерживают либо несколько ориентаций, либо одну, при которой ориентация поверхности рендеринга отличается от ориентации устройства по умолчанию. Например, приложение, поддерживающее только альбомную ориентацию, на телефоне с портретной ориентацией или приложение, поддерживающее только портретную ориентацию, на планшете с альбомной ориентацией.
Измените файл AndroidManifest.xml
Чтобы приложение могло обрабатывать поворот устройства, сначала измените файл AndroidManifest.xml. Это позволит сообщить Android, что приложение будет обрабатывать изменения ориентации и размера экрана. Это предотвращает уничтожение и повторное создание объекта Activity Android, а также вызов функции onDestroy() для существующей поверхности окна при изменении ориентации. Для этого добавьте атрибуты orientation (для поддержки уровня API ниже 13) и screenSize в раздел configChanges.
<activity android:name="android.app.NativeActivity"
android:configChanges="orientation|screenSize">
Если в вашем приложении ориентация экрана фиксируется с помощью атрибута screenOrientation, то делать это не нужно. Если в приложении используется фиксированная ориентация, то цепочку буферов нужно будет настроить только один раз при запуске или возобновлении работы приложения.
Как получить разрешение экрана и параметры камеры
Затем определите разрешение экрана устройства, связанное со значением VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. Это разрешение связано с ориентацией устройства, поэтому цепочка буферов всегда должна быть настроена на него. Самый надежный способ получить эти данные – вызвать 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 возвращают VK_SUBOPTIMAL_KHR из vkQueuePresentKHR(). Результат этой проверки сохраняется в переменной orientationChanged типа boolean, доступной из основного цикла рендеринга приложения.
Как отслеживать изменения ориентации устройства (версии до Android 10)
Для устройств с Android 10 или более ранней версией требуется другая реализация, поскольку VK_SUBOPTIMAL_KHR не поддерживается.
Использование опросов
На устройствах с более ранними версиями Android можно опрашивать текущее преобразование устройства каждые pollingInterval кадров, где pollingInterval – это степень детализации, определяемая программистом. Для этого вызовите vkGetPhysicalDeviceSurfaceCapabilitiesKHR() и сравните возвращенное поле currentTransform с полем текущего преобразования поверхности (в этом примере кода оно хранится в pretransformFlag).
currFrameCount++;
if (currFrameCount >= pollInterval){
VkSurfaceCapabilitiesKHR capabilities;
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);
if (pretransformFlag != capabilities.currentTransform) {
window_resized = true;
}
currFrameCount = 0;
}
На устройстве Pixel 4 с Android 10 опрос vkGetPhysicalDeviceSurfaceCapabilitiesKHR() занял 0,12–0,25 мс, а на Pixel 1XL с Android 8 – 0,11–0,35 мс.
Использование обратных вызовов
Второй вариант для устройств с версией 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(). Это означает, что вы:
Удалите все существующие экземпляры
FramebufferиImageView.Пересоздайте цепочку буферов, удалив старую (об этом мы поговорим далее).
Повторно создайте буферы кадров с помощью 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, чтобы показать, что вы обработали изменение ориентации.
Пересоздание цепочки буферов
В предыдущем разделе мы упоминали о необходимости воссоздать цепочку буферов. Первые шаги для этого включают получение новых характеристик поверхности рендеринга:
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 для цепочки буферов, которая будет удалена.
Корректировка матрицы 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 во время создания конвейера 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 (например, целое число, представляющее текущую ориентацию устройства) и использовать ее для правильного сопоставления вычислений производных:
- Для кадра, повернутого на 90 градусов:
- dFdx необходимо сопоставить с dFdy.
- dFdy необходимо сопоставить с -dFdx.
- Для кадра, повернутого на 270 градусов:
- dFdx необходимо сопоставить с -dFdy.
- dFdy должно быть сопоставлено с dFdx.
- Для кадра, повернутого на 180 градусов:
- dFdx должно быть сопоставлено с -dFdx.
- dFdy необходимо сопоставить с -dFdy.
Заключение
Чтобы приложение могло максимально эффективно использовать Vulkan на Android, необходимо реализовать предварительный поворот. Вот основные выводы из этой статьи:
- Убедитесь, что при создании или повторном создании цепочки буферов флаг pretransform соответствует флагу, возвращенному операционной системой Android. Это позволит избежать накладных расходов на компоновщик.
- Зафиксируйте размер цепочки буферов изображения в соответствии с разрешением поверхности окна приложения в естественной ориентации экрана.
- Поверните матрицу MVP в пространстве отсечения, чтобы учесть ориентацию устройства, поскольку разрешение/размер цепочки буферов больше не обновляется вместе с ориентацией дисплея.
- Обновите область просмотра и прямоугольники отсечения в соответствии с требованиями приложения.
Пример приложения: минимальная предварительная ротация на Android