Skip to content

RHI

Lumina’s RHI is not a class hierarchy. It is a flat set of free functions in the Lumina::RHI namespace over opaque handles, declared in Engine/Source/Runtime/Renderer/RHI.h and implemented per backend. There is one backend today, Vulkan (Renderer/API/Vulkan/VulkanRHI.cpp). The enum reserves Metal and DX12 slots, both unimplemented.

There is no OpenGL backend, and there is no render graph. Passes are recorded by hand into command lists, in an explicit order, with explicit barriers.

RHI::FCmdListH CL = RHI::OpenCommandList();
RHI::CmdSetTextureHeap(CL, RHI::Core::GetGlobalHeap());
RHI::CmdBeginRenderPass(CL, PassDesc);
RHI::CmdSetPipeline(CL, Pipeline);
RHI::CmdDrawIndexed(CL, IndexBuffer, 0, Args, IndexCount, 1, 0, 0, 0);
RHI::CmdEndRenderPass(CL);
RHI::Core::Submit(CL);

Four design decisions explain most of what you will read:

  1. Handles, not objects. THandle<T> wraps an integer. RHI::IsValid(H) tests it. There is no vtable, no reference counting, and no IRHITexture interface to implement.
  2. Buffers are raw GPU addresses. There is no buffer object at all. RHI::Malloc returns a GPUPtr (a uint64 device address), and shaders dereference it directly through buffer device address. RHI::ToHost(GPUPtr) gives you the mapped CPU pointer for host-visible memory.
  3. Everything a shader reads is bindless. There are no descriptor sets in the API. Textures, storage images, and samplers live in a texture heap and are addressed by an integer slot. One heap is bound per command list.
  4. Draw arguments are a pointer. Every Cmd*Draw / CmdDispatch takes a GPUPtr DrawArgs that becomes the shader’s push-constant payload. Passing per-draw data means writing a struct into transient memory and handing over its address.
using FPipelineH = THandle<FPipeline>;
using FTextureH = THandle<FTexture>;
using FTextureHeapH = THandle<FTextureHeap>;
using FSemaphoreH = THandle<FSemaphore>;
using FDepthStencilH = THandle<FDepthStencilState>;
using FCmdListH = THandle<FCommandList>;
using FSwapchainH = THandle<FSwapchain>;
using FSurfaceH = THandle<FSurface>;

Every handle type has a FreeH overload. RAII wrappers exist and should be preferred for anything with a lifetime longer than a function:

  • RHI::TUniqueH<T> (aliased as FPipelineUH, FTextureUH, FTextureHeapUH, FSemaphoreUH, FDepthStencilUH) frees the handle on destruction.
  • RHI::FUniqueGPUPtr does the same for a GPUPtr.
GPUPtr Malloc(uint64 Size, uint64 Alignment = 16, EMemoryType Type = EMemoryType::Default);
void* ToHost(GPUPtr GPU);
void Free(GPUPtr GPU);

EMemoryType is CPUWrite (the default), CPURead, or GPUOnly. Allocations above kDedicatedMemoryThreshold (32 MB) get a dedicated allocation.

RHI::New<T>(Count) allocates and value-constructs an array, returning both the host pointer and the GPU address:

auto [Host, Gpu] = RHI::New<FMyStruct>(Count);
Host[0].Value = 1.0f;
// pass Gpu to a shader

Freeing memory the GPU may still be reading is the classic bug here. Use RHI::Core::DeferredFree(Ptr, ExtraFrames) instead of RHI::Free for anything referenced by submitted work. It retires the allocation once every in-flight frame has completed. ExtraFrames extends that window for memory whose address can still be handed out after the free was queued, for instance a game-thread cache that will return the old address for one more tick.

RHI::Core::AllocTransient(Size, Alignment) is a per-frame bump allocator over CPU-write, device-addressable memory. It is thread safe (atomic bump) and valid until its frame slot is reused.

GPUPtr Args = RHI::Core::CopyTransient(MyPushConstants);
GPUPtr Data = RHI::Core::CopyTransientArray(Items.data(), Items.size());

This is the intended way to pass per-draw and per-pass data. It is not for geometry. Vertex and index data belong in a persistent allocation; the transient ring is sized for small per-frame payloads and cycling megabytes of mesh data through it will exhaust it.

FTextureDesc Desc
{
.Type = ETextureType::Tex2D,
.Dimension = { Width, Height, 1 },
.MipCount = Mips,
.LayerCount = 1,
.Format = EFormat::RGBA8_UNORM,
.Usage = EImageUsageFlags::Sampled | EImageUsageFlags::TransferDst,
};
RHI::FTextureH Texture = RHI::CreateTexture(Desc);

Types cover 1D, 2D, 3D, cube, 2D array, and cube array. Usage flags are Sampled, Storage, ColorAttachment, DepthAttachment, TransferSrc, TransferDst.

CreateTexture takes an optional GPUPtr Location to place the image in memory you already own. GetTextureDesc(Texture) reads a description back.

Copies and blits operate on FTextureSlice (mip, layer, layer count, offset, extent; an extent of 0 means the full mip):

CmdCopyTexture, CmdCopyMemoryToTexture, CmdCopyTextureToMemory,
CmdBlitTexture, CmdResolveTexture, CmdClearTexture, CmdClearTextureUInt
FTextureHeapH CreateTextureHeap(uint32 TextureCount, uint32 RWTextureCount, uint32 SamplerCount);
uint32 HeapWriteTexture(FTextureHeapH Heap, FTextureH Texture);
uint32 HeapWriteRWTexture(FTextureHeapH Heap, FTextureH Texture, uint32 Mip = 0);
uint32 HeapWriteSampler(FTextureHeapH Heap, const FSamplerDesc& Desc);
void HeapFreeTexture / HeapFreeRWTexture / HeapFreeSampler(Heap, uint32 Slot);

Each HeapWrite* returns a slot index. That integer is what you put in a shader struct; the shader indexes the heap with it. kInvalidHeapSlot (~0u) marks an unset slot.

Binding slots inside the heap are fixed:

ConstantValueContents
kSamplerBindingSlot0Samplers
kImageBindingSlot1Sampled images
kRWImageBindingSlot2Storage images

Limits: kMaxTextureHeapSize (INT16_MAX) entries, kMaxNumSamplers (4000), kMaxNumTextureHeaps (1024).

RHI::Core::GetGlobalHeap() is the process-wide heap that virtually everything uses. CmdSetTextureHeap(CL, Heap) binds it, and every render path does this as its first command.

Slot lifetime is the sharp edge. A slot freed while a submitted command list still references it produces a GPU-side read of a destroyed image. Free heap slots on the same deferred schedule as the resources behind them.

Stock samplers are registered by Core::Initialize in a fixed order, exposed as EStockSampler: LinearWrap, LinearClamp, LinearMirror, PointWrap, PointClamp, AnisoWrap, AnisoClamp, Shadow, MinReduction, MaxReduction. These must stay in lockstep with the SAMPLER_* constants in GlobalRHI.slang.

GetTextureHeapTextures(Heap, Out) enumerates every occupied sampled slot, which is what the editor’s GPU resource views display.

FPipelineH CreateGraphicsPipeline(const FShaderSource& Vertex, const FShaderSource& Fragment,
const FRasterDesc& Desc, TSpan<const FSpecializationConstant> = {});
FPipelineH CreateComputePipeline(const FShaderSource& Compute, TSpan<const FSpecializationConstant> = {});
FPipelineH CreateMeshShaderPipeline(const FShaderSource& Task, const FShaderSource& Mesh,
const FShaderSource& Fragment, const FRasterDesc& Desc,
TSpan<const FSpecializationConstant> = {});

FShaderSource is a SPIR-V byte span plus an entry point name. RHI::Core::CreateGraphicsPipeline(VertexName, PixelName, Desc) is the convenience form that pulls bytecode from the engine shader library by FName.

FRasterDesc carries topology, sample count, wireframe, alpha to coverage, the depth and stencil formats, and the color target list (each with its own FBlendDesc). There is no vertex input layout: vertices are pulled from buffers through device addresses in the shader.

Mesh shader pipelines require RHI::SupportsMeshShaders(); the call returns an invalid handle otherwise. The task stage is optional (an empty source means a mesh-only pipeline).

Depth and stencil state is a separate object (CreateDepthStencil) bound with CmdSetDepthStencilState, because the engine reuses one pipeline across different depth modes.

Front face, cull mode, line width, viewport, scissor, depth and stencil state, and the index buffer are all dynamic:

CmdSetFrontFace, CmdSetCullMode, CmdSetLineWidth,
CmdSetViewport, CmdSetScissor,
CmdSetDepthStencilState, CmdSetIndexBuffer

This keeps the pipeline permutation count down: the same pipeline serves both cull modes and every viewport size.

FCmdListH OpenCommandList(EQueueType Type = EQueueType::Graphics);
void ResetCommandList(FCmdListH CL);
void Submit(EQueueType Queue, TSpan<const FCmdListH> CLs,
TSpan<const FSemaphoreInfo> Waits = {}, TSpan<const FSemaphoreInfo> Signals = {});
void Submit(FCmdListH CL, EQueueType Type = EQueueType::Graphics);
void SubmitAndWait(FCmdListH CL);

EQueueType is Graphics, Transfer, or Compute.

SubmitAndWait submits on the graphics queue and blocks until only that submission completes, by waiting its own frame-timeline value. Use it for one-off captures. Do not use Submit followed by WaitDeviceIdle from inside a render command: WaitDeviceIdle blocks on unrelated in-flight frame work while holding the cooperative drain, which hangs the next FlushRenderingCommands.

Command list recording is single threaded per list. Multiple scenes may record concurrently because each opens its own list; shared resource creation inside the RHI is internally locked.

Semaphores are timeline semaphores:

FSemaphoreH CreateSemaphore(uint64 InitialValue);
void WaitSemaphore(FSemaphoreH Semaphore, uint64 Value);

FSemaphoreInfo { Semaphore, Value, Stage } is what you pass to Submit as a wait or a signal.

Barriers are expressed as stage-to-stage transitions, not as per-resource transitions:

void CmdBarrier(FCmdListH CL, EStageFlags Before, EStageFlags After);

EStageFlags covers IndirectArguments, Transfer, Compute, RasterColorOut, PixelShader, FragmentTests, VertexShader, Host, MeshShader, TaskShader, and AllCommands.

The RHI::Barriers namespace holds the canonical combinations, and passes should use these rather than hand-rolling stage masks:

HelperOrders
ComputeToAllCompute writes before any later read, including indirect args.
RasterToReadColor and depth writes before shader reads.
RasterToRasterAttachment writes before the next attachment writes.
TransferToAllCopies before everything.
TransferToTransferCopy before copy (resolves write-after-write hazards between two copies to the same image).
AllToTransferEverything before a copy.

Image layout transitions are handled by the backend. See Vulkan Backend for how unified image layouts remove most of that bookkeeping.

CmdBeginRenderPass / CmdEndRenderPass take an FRenderPassDesc of color attachments, a depth attachment, a stencil attachment, and a render area. Each FRenderAttachment has a texture, an optional MSAA resolve target, a load op, a store op, and a clear color. This maps onto Vulkan dynamic rendering; there are no VkRenderPass or framebuffer objects.

CmdDraw(CL, DrawArgs, VertexCount, InstanceCount, FirstVertex, FirstInstance);
CmdDrawIndexed(CL, IndexBuffer, IndexOffset, DrawArgs, IndexCount, InstanceCount, FirstIndex, VertexOffset, FirstInstance, IndexType);
CmdDrawIndirect(CL, DrawArgs, IndirectBuffer, Offset, DrawCount, Stride);
CmdDrawIndexedIndirect(CL, DrawArgs, Offset, DrawCount, Stride);
CmdDispatch(CL, DrawArgs, GroupX, GroupY, GroupZ);
CmdDispatchIndirect(CL, DrawArgs, IndirectBuffer, Offset);
CmdDrawMeshTasks(CL, DrawArgs, GroupCountX, GroupCountY, GroupCountZ);
CmdDrawMeshTasksIndirect(CL, DrawArgs, IndirectBuffer, Offset, DrawCount, Stride);
CmdDrawMeshTasksIndirectCount(CL, DrawArgs, IndirectBuffer, Offset, CountBuffer, CountOffset, MaxDrawCount, Stride);

Indirect argument structs mirror the Vulkan ones: FDrawIndirectArguments, FDrawIndexedIndirectArguments, FDispatchIndirectArguments, and FDrawMeshTasksIndirectArguments.

void CreateDevice(const FDeviceDesc& Desc = {}); // bValidation, bDebugUtils
void FreeDevice();
void WaitDeviceIdle();
void TickFrame();
FSurfaceH CreateSurface(void* WindowHandle); // MAIN THREAD ONLY
FSwapchainH CreateSwapchain(FSurfaceH Surface, const FUIntVector2& Extent);
void RecreateSwapchain(FSwapchainH Swapchain, const FUIntVector2& Extent);
FTextureH AcquireNextImage(FSwapchainH Swapchain); // invalid handle if out of date
bool PresentSwapchain(FSwapchainH, FCmdListH Final, FSemaphoreH FrameSignal, uint64 Value);
void SetVSync(bool) / bool GetVSync();

CreateSurface must be called on the thread that owns the window, because GLFW’s window calls are main-thread only. The handle is then passed to the render side, where CreateSwapchain consumes it and takes ownership. FreeH on a surface is only for the case where the window died before a swapchain was built.

AcquireNextImage returning an invalid handle means out of date; recreate the swapchain and skip the frame.

kFramesInFlight is 3.

FGPUDeviceInfo GetDeviceInfo(); // name, API version string, vendor ID, discrete
void GetGPUMemoryStats(FGPUMemoryStats& Out);
bool SupportsMeshShaders();
ICrashTracker& GetCrashTracker();
void HandleDeviceLost();

FGPUMemoryStats breaks down per heap: budget and usage as reported by the OS, plus allocated and block bytes from the allocator. The gap between allocated and block bytes is fragmentation and reserve. bReBAR marks a heap that is both device local and host visible and larger than the legacy 256 MB BAR window.

SymptomCause
GPU reads garbage from a buffer freed last frameRHI::Free instead of Core::DeferredFree.
Validation error about a destroyed image still in a descriptorA heap slot was freed before the frames referencing it retired.
Hang on the next FlushRenderingCommandsWaitDeviceIdle called from inside a render command. Use SubmitAndWait.
Transient allocation failure mid-frameGeometry or large buffers pushed through the transient ring.
Crash creating a surfaceCreateSurface called off the main thread.
Mesh shader pipeline handle is invalidSupportsMeshShaders() is false on this device.
Write-after-write hazard between two copiesMissing Barriers::TransferToTransfer between them.