Графика и вычисления
Основной модуль графического API: xenolith_core
. Единственный действующий на текущий момент графический API: Vulkan (xenolith_backend_vk
).
Для работы необходимы установленные в системе заголовки Vulkan или Vulkan SDK. Для сборки шейдеров необходимы glslangValidator
, spirv-link
.
Далее описсан низкоуровневый процесс запуска графического API. Обычно, приложения используют модуль приложения xenolith_application
, в которых этот процесс уже реализован.
Интерфейс построен по заведомо асинхронной модели и активно использует потоки. Однопоточный режим работы не предусмотрен.
Инициализация API
Для работы необходим объект xenolith::core::Instance
. Для Vulkan:
#include "XLVkPlatform.h"
using namespace stappler::xenolith;
auto instance = vk::platform::createInstance([&] (vk::platform::VulkanInstanceData &data, const vk::platform::VulkanInstanceInfo &info) {
data.applicationName = "app.name";
data.applicationVersion = "1.0.0";
// включение слоёв и расширений из info
return true;
});
Создание графического цикла
Все вычислительные и графические операции выполняются изолированно в графическом цикле (xenolith::core::Loop
).
#include "XLVkInstance.h"
using namespace stappler::xenolith;
// контроль устройства, функций и расширений
auto data = Rc<vk::LoopData>::alloc();
data->deviceSupportCallback = [] (const vk::DeviceInfo &dev) {
return dev.requiredExtensionsExists && dev.requiredFeaturesExists;
};
data->deviceExtensionsCallback = [] (const vk::DeviceInfo &dev) -> xenolith::Vector<StringView> {
// дополнительные расширения
return xenolith::Vector<StringView>();
};
data->deviceFeaturesCallback = [] (const vk::DeviceInfo &dev) -> vk::DeviceInfo::Features {
// дополнительные функции
return vk::DeviceInfo::Features();
};
// создаём цикл
core::LoopInfo loopInfo;
loopInfo.platformData = data;
auto loop = instance->makeLoop(move(loopInfo));
// инициализация цикла ничинается сразу же в другом потоке
// разумно провести инициализацию других компонентов, прежде, чем дожидаться окончательного запуска цикла
// это может значительно ускорить запуск приложения
// дожидаемся запуска цикла
loop->waitRinning();
// после этого цикл полностью работоспособен
Очереди исполнения
Для выполнения реальных задач необходима очередь исполнения core::Queue
, это реализация концепции Render Graph в Xenolith.
Структура очереди отталкивается от элемента вложения core::Attachment
, представляющего буфер либо изображение, над которым происходит работа. Для определения порядка работы используется параметр core::RenderOrdering
, чем он ниже, тем раньше будет выполнен проход. Упоряддочивается выполнение только тех проходов, что завязаны на одни и те же вложения, в остальном порядок будет определяться порядком поступоления данных для работы.
Система НЕ линеаризует очередь, в отличии от классических систем RenderGraph, поскольку предполагает, что в асинхронной системе не определён порядок, в котором могут поступить входящие данные. То есть, проходы запускаются при первой возможности, как только данные для них готовы.
Общая структура:
Queue
Attachment
- очередь содержит одно и более вложенийAttachmentPassData
- дескриптор вложения связывает вложение и проходAttachmentSubpassData
- ссылка на дескриптор вложения для подпрохода, создаётся при добавлении дескриптора к подпроходу
Program
- исполняемая на устройстве программаResource
- ресурсные данные офередиImage
- статические изображенияBuffer
- статические буферы
QueuePass
- проход рендеринга/вычисления/трансфераAttachmentPassData
- привязанные к проходу вложенияDescriptorLayout
- варианты укладки дескрипторов для пайплайновDescriptorSet
- набор дескрипторовDescriptor
- отдельный дескриптор, связанный с вложением (AttachmentPassData)
Subpass
- подпроход, выполняющий реальную работуAttachmentSubpassData
- связанные с подпроходом вложенияGraphicPipeline
- графические пайплайны, привязаны к укладке дескрипторовComputePipeline
- вычислительные пайплайны, привязаны к укладке дескрипторов
Классы Attachment
и QueuePass
используются в качестве базовых при конкретной реализации вложений и проходов.
В очереди подробно указываются правила переходов и синхронизаций, механизм в целом дублирует аналогичный из Vulkan (https://gpuopen.com/learn/vulkan-barriers-explained/).
Очередь не делает больше, чем доступно в используемом графическом API. То есть, несмотря на то, что в системе существует вычислительный и трансферный абстрактные варианты прохода, поскольку их нет в Vulkan - автоматические синхронизации для них не выполняются, их необходимо выполнить вручную командами.
Описанный интерфейс не зависит от платформы и типа приложения. Это базовые строительные блоки для создания приложений, использующих графический API, которые можно использовать и из командной строки, и на сервере, и в графическом приложении.
Пример определения очереди:
struct NoiseData {
uint32_t seedX;
uint32_t seedY;
float densityX;
float densityY;
};
struct NoiseDataInput : core::AttachmentInputData {
NoiseData data;
};
class NoiseQueue : public core::Queue {
public:
virtual ~NoiseQueue() = default;
bool init();
const AttachmentData *getDataAttachment() const { return _dataAttachment; }
const AttachmentData *getImageAttachment() const { return _imageAttachment; }
protected:
const AttachmentData *_dataAttachment = nullptr;
const AttachmentData *_imageAttachment = nullptr;
};
class NoisePass : public vk::QueuePass {
public:
virtual ~NoisePass() = default;
virtual bool init(Queue::Builder &queueBuilder, QueuePassBuilder &, const AttachmentData *, const AttachmentData *);
const AttachmentData *getDataAttachment() const { return _dataAttachment; }
const AttachmentData *getImageAttachment() const { return _imageAttachment; }
protected:
using QueuePass::init;
const AttachmentData *_dataAttachment = nullptr;
const AttachmentData *_imageAttachment = nullptr;
};
bool NoiseQueue::init() {
using namespace core;
Queue::Builder builder("Noise");
// входящий буфер
auto dataAttachment = builder.addAttachemnt("NoiseDataAttachment", [&] (AttachmentBuilder &attachmentBuilder) -> Rc<Attachment> {
// помечаем как ожидающий ввода
attachmentBuilder.defineAsInput();
auto a = Rc<vk::BufferAttachment>::create(attachmentBuilder, core::BufferInfo(
core::BufferUsage::UniformBuffer, sizeof(NoiseData)
));
// проверка входящих данных на валидность
a->setValidateInputCallback([] (const Attachment &, const Rc<AttachmentInputData> &data) {
return dynamic_cast<NoiseDataInput *>(data.get()) != nullptr;
});
// создание интерфейса кадра
a->setFrameHandleCallback([] (Attachment &a, const FrameQueue &queue) {
auto h = Rc<vk::BufferAttachmentHandle>::create(a, queue);
// обработка входящих данных
h->setInputCallback([] (AttachmentHandle &handle, FrameQueue &queue, Rc<AttachmentInputData> &&input, Function<void(bool)> &&cb) {
auto a = static_cast<vk::BufferAttachment *>(handle.getAttachment().get());
auto d = static_cast<NoiseDataInput *>(input.get());
auto devFrame = static_cast<vk::DeviceFrameHandle *>(queue.getFrame().get());
auto b = static_cast<vk::BufferAttachmentHandle *>(&handle);
// аллоцируем кадровый буфер
auto buf = devFrame->getMemPool(devFrame)->spawn(vk::AllocationUsage::DeviceLocalHostVisible, a->getInfo());
// записываем в него данные
buf->map([&] (uint8_t *data, VkDeviceSize) {
memcpy(data, &d->data, sizeof(NoiseData));
});
// добавляет новый буфер в дескрипторы для доступа из шейдера
b->addBufferView(buf);
cb(true);
});
return h;
});
return a;
});
// исходящее изображение
auto imageAttachment = builder.addAttachemnt("NoiseImageAttachment", [&] (AttachmentBuilder &attachmentBuilder) -> Rc<Attachment> {
// помечаем как исходящий (его можно захватыватьн а выходе из кадра)
attachmentBuilder.defineAsOutput();
return Rc<vk::ImageAttachment>::create(attachmentBuilder,
ImageInfo(Extent2(1024, 768), ImageUsage::Storage | ImageUsage::TransferSrc, ImageTiling::Optimal, PassType::Compute, ImageFormat::R8G8B8A8_UNORM),
ImageAttachment::AttachmentInfo{
.initialLayout = AttachmentLayout::Undefined,
.finalLayout = AttachmentLayout::General,
.clearOnLoad = true,
.clearColor = Color4F(0.0f, 0.0f, 0.0f, 0.0f)}
);
});
// добавляем вычислительный проход
builder.addPass("NoisePass", PassType::Compute, RenderOrdering(0), [&] (QueuePassBuilder &passBuilder) -> Rc<core::QueuePass> {
return Rc<NoisePass>::create(builder, passBuilder, dataAttachment, imageAttachment);
});
if (core::Queue::init(move(builder))) {
_dataAttachment = dataAttachment;
_imageAttachment = imageAttachment;
return true;
}
return false;
}
bool NoisePass::init(Queue::Builder &queueBuilder, QueuePassBuilder &builder, const AttachmentData *data, const AttachmentData *image) {
using namespace core;
// связываем изображение с проходом
auto passImage = builder.addAttachment(image, [] (AttachmentPassBuilder &builder) {
builder.setDependency(AttachmentDependencyInfo{
PipelineStage::ComputeShader, AccessType::ShaderWrite,
PipelineStage::ComputeShader, AccessType::ShaderWrite,
FrameRenderPassState::Complete,
});
});
// добавляем укладку дескрипторов
auto layout = builder.addDescriptorLayout([&] (PipelineLayoutBuilder &layoutBuilder) {
layoutBuilder.addSet([&] (DescriptorSetBuilder &setBuilder) {
setBuilder.addDescriptor(builder.addAttachment(data));
setBuilder.addDescriptor(passImage, DescriptorType::StorageImage, AttachmentLayout::General);
});
});
// добавляем рабочий подпроход
builder.addSubpass([&] (SubpassBuilder &subpassBuilder) {
// добавляем шейдер (в очередь) и пайплайн (в подпроход)
subpassBuilder.addComputePipeline("NoisePipeline", layout,
queueBuilder.addProgramByRef("NoisePipelineComp", NoiseComp));
// добавляем рабочую функцию
subpassBuilder.setCommandsCallback([&] (const SubpassData &subpass, FrameQueue &frame, CommandBuffer &commands) {
// получаем конкретные реализции обектов из абстрактных, представляемых очередью
auto &buf = static_cast<vk::CommandBuffer &>(commands);
// получаем данные вложения для текущего кадра и извлекаем изображение
auto imageAttachment = static_cast<vk::ImageAttachmentHandle *>(frame.getAttachment(_imageAttachment)->handle.get());
auto image = (vk::Image *)imageAttachment->getImage()->getImage().get();
// запрощенный размер изображения определяем как размер текущего кадра
auto extent = frame.getFrame()->getFrameConstraints().extent;
// синхронизируем изображение (обязательный шаг, поскольку система использует агрессивное кеширование
// и это, вероятно, то же самое изображение, что было в предыдущем кадре
vk::ImageMemoryBarrier inImageBarriers[] = {
vk::ImageMemoryBarrier(image, 0, VK_ACCESS_SHADER_WRITE_BIT, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL)
};
buf.cmdPipelineBarrier(VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0,
inImageBarriers);
// связываем дескрипторы (они заполняются автоматически)
buf.cmdBindDescriptorSets(static_cast<vk::RenderPass *>(subpass.pass->impl.get()), 0);
// получаем и связываем пайплайн
auto pipeline = (vk::ComputePipeline *)subpass.computePipelines.get("NoisePipeline")->pipeline.get();
buf.cmdBindPipeline(pipeline);
// запускаем вычисления
buf.cmdDispatch((extent.width - 1) / pipeline->getLocalX() + 1, (extent.height - 1) / pipeline->getLocalY() + 1);
});
});
_dataAttachment = data;
_imageAttachment = image;
return QueuePass::init(builder);
}
Запуск графики и вычислений
Для запуска очереди необходимо создать запрос на кадр: core::FrameRequest
.
// запускаем с функцией получения данных изображения
static void run(core::Loop *loop, NoiseQueue *noiseQueue, NoiseData noiseData, Function<void(core::ImageInfoData info, BytesView view)> &&cb) {
// параметры кадра
auto constraints = core::FrameContraints{Extent2(1024, 768)};
auto req = Rc<core::FrameRequest>::create(noiseQueue, constraints);
// Входящие данные для вложения
//
// Не обязательно всегда полностью предоставлять входящие данные при запуске кадра
// Система запустит те проходы, которые возможно запустить с предоставленными данными, и будет ожидать ввода для остальных.
// После запуска данные можно отправлять той же функцией, но только из графического потока (см. Loop::perform).
auto inputData = Rc<NoiseDataInput>::alloc();
inputData->data = noiseData;
req->addInput(noiseQueue->getDataAttachment(), move(inputData));
// захватываем выход
req->setOutput(noiseQueue->getImageAttachment(), [loop, cb = move(cb)] (core::FrameAttachmentData &data, bool success, Ref *) mutable {
// захватываем изображение и переносим на сторону CPU
loop->captureImage([cb = move(cb)] (core::ImageInfoData info, BytesView view) {
cb(info, view);
}, data.image->getImage(), core::AttachmentLayout::General);
return true;
});
// запускам кадр (последний аргумент - поколение вызовов, используется для контроля изменений состояния приложения)
// после этого вызова кадр обрабатывается полностью асинхронно. Функция будет вызвана в графическом потоке, и результат, вероятно, нужно будет перенести в основной поток.
loop->runRenderQueue(move(req), 0);
}