Step 28 · Graphics and performance
The Vulkan backend
The last leg: turning accumulated state into real Vulkan objects, getting texture memory into a layout the GPU can sample, and presenting the result. This is also where most performance answers live.
Before this page: How a GPU actually draws · Submitted is not completed. View the learning route.
The host rendering job
The host backend obtains buffers and images, binds a pipeline and resources, records commands, submits them, and eventually presents an image. Recording or submitting a command does not mean the device finished it. Reused objects need correct contents, layouts and lifetime as well as a matching cache key.
First follow that sequence and the completion timeline. Read device features, tiling formulas, cache keys and alias synchronization as deeper reference, then follow D3 with the current source.
Can a cached image always be reused because its guest address matches?
No. The format, extent, usage and current contents must still match the required view, and synchronization and lifetime must be valid. An address is only part of the resource identity.
Use the code-example guide: snippets below are source excerpts or labelled schematic sequences, not standalone programs.
The host side. Its central job is caching: turning "this guest address range, in this format, used this way" into "this Vulkan object" — and knowing when that answer stops being true.
Device setup
inline constexpr uint32_t VULKAN_TARGET_API_VERSION = VK_API_VERSION_1_3;
struct GraphicContext {
vk::Instance instance;
vk::PhysicalDevice physical_device;
vk::Device device;
VmaAllocator allocator;
bool memory_budget_ext_enabled;
bool supports_block_texel_view;
bool compute_subgroup_size_control_enabled;
bool sample_rate_shading_enabled;
bool mesh_shader_enabled; // VK_EXT_mesh_shader, for merged geometry stages
uint32_t subgroup_size, min_subgroup_size, max_subgroup_size;
uint32_t max_push_descriptors;
vk::ShaderStageFlags required_subgroup_size_stages;
Common::Mutex queue_mutex;
uint32_t queue_family;
vk::Queue queue;
...
};
One queue, guarded by a mutex. The 57 guest queues of PM4 & the command processor are multiplexed onto it — ordering is preserved through timeline semaphores rather than through hardware queues.
Format capability queries are memoised, because they are asked constantly and are not cheap:
vk::FormatProperties GetFormatProperties(vk::Format format) const {
std::scoped_lock lock(m_format_properties_mutex);
auto [it, inserted] = m_format_properties.try_emplace(format);
if (inserted) physical_device.getFormatProperties(format, &it->second);
return it->second;
}
Resource managers and caches
| Cache | Keyed by | Notes |
|---|---|---|
TextureCache 77 KB | address + format + extent + binding type | Aliasing, depth/stencil pairing, mip expansion, DCC/HTile metadata clears, upload, download and LRU collection. It is owned with the buffer cache by RenderContext. |
BufferCache 28 KB | guest address range | Page-indexed vertex, index, uniform and storage buffers, batched uploads/downloads, page tracking for DMA fills and copies, LRU collection and the BDA fault path. |
PipelineCache 36 KB | shader IDs + non-dynamic static state + attachment formats | Owns the program cache (translation → per-specialization SPIR-V), the pipeline map, and a per-game Vulkan driver cache saved to disk. Logs compiled shader counts per guest stage. |
DescriptorHeap 3 KB | descriptor-set layout | Allocates sets in batches of 32 and retires whole Vulkan descriptor pools against timeline ticks. |
SamplerCache 6.5 KB | the guest S# | Small and simple by comparison. |
FaultManager 7 KB | faulted BDA page ranges | Nested in BufferCache; processes RDNA2 FLAT/BDA misses through a GPU fault buffer and page table, including faults at the very edge of a mapping. |
The pipeline key
Vulkan bakes fixed-function state into the pipeline object, so every state difference needs a distinct pipeline — unless the state is declared dynamic and set per draw instead. Kyty packs everything that is not dynamic into one byte-comparable struct:
#pragma pack(push, 1)
struct PipelineStaticParameters {
bool negative_one_to_one, depth_clip_enable;
vk::PrimitiveTopology topology;
bool primitive_restart_enable;
uint32_t samples;
bool sample_shading_enable;
uint32_t color_mask[8];
bool cull_front, cull_back, face, provoking_vtx_last;
vk::PolygonMode polygon_mode;
uint8_t color_srcblend[8], color_comb_fcn[8], color_destblend[8];
uint8_t alpha_srcblend[8], alpha_comb_fcn[8], alpha_destblend[8];
bool separate_alpha_blend[8], blend_enable[8];
};
#pragma pack(pop)
static_assert(sizeof(PipelineStaticParameters) == 116);
struct GraphicsPipelineKey {
PipelineRenderingState rendering;
std::array<uint64_t, 3> vertex_shader_ids; // VS/mesh, or LS/HS/TES
uint64_t ps_shader_id;
PipelineVertexInputState vertex_input;
PipelineStaticParameters static_params;
};
The packing and the size assertion exist because operator== is a memcmp and the hash uses XXH3_64bits on the packed static state. Padding bytes would be uninitialised, so identical states could hash differently — producing a slow, silent pipeline explosion. The static_assert makes any accidental padding a build error.
Viewport, scissor, depth test/write/compare, depth-bounds enable/range, stencil test/operations/masks/reference and blend constants are Vulkan dynamic state set on the command buffer per draw, so they are not in the key. Fewer distinct keys means fewer first-encounter compiles. The packed struct is 116 bytes.
PipelineCache now has two levels. Kyty's maps avoid rebuilding a program or
pipeline during one run; a per-game VkPipelineCache is also loaded and saved so the
driver can reuse its own compiled data on the next run. The driver cache improves creation
time but does not replace Kyty's exact C++ keys.
Renderer ownership and transfers
RenderContext directly owns PageManager, BufferCache, TextureCache and the mapped-range set. Its fault, invalidation, map/unmap, BDA preparation and garbage-collection methods replaced the separate GPU-resource manager. The owning renderer serializes pipeline-cache use, including saving while the GPU is running; the cache is not independently thread-safe.
Buffer readback is batched through a 64 MiB download ring. Clean guest buffers can stream directly; GPU-written aliases still require synchronization before CPU use. Image dirty-region queries honor CPU ownership and image-local readback validity. Texture alias resolution shares canonical layouts across linear/tiled, 1D/2D, mip-tail and depth/stencil views. DCC/CMASK clears must become real pixels before an expanded alias or video-out consumer can use them.
Draw preparation reuses shader bindings and reads vertex semantics directly from metadata. Release-label work is batched without redundant submissions; indirect dispatch counts are read from GPU buffers. These changes reduce per-draw work while keeping ordering and resource lifetimes explicit.
Command submission
class CommandScheduler {
public:
void Begin(HW::Context&, HW::UserConfig&, HW::Shader&);
void BeginRendering(const RenderState&);
void Flush();
void Finish();
void Wait(uint64_t tick);
void DeferOperation(Common::UniqueFunction<void>&&);
void DeferPriorityOperation(Common::UniqueFunction<void>&&);
...
private:
MasterSemaphore m_master; // timeline semaphore, monotonic tick
class CommandPool {
static constexpr size_t GrowStep = 4;
std::vector<vk::CommandBuffer> m_buffers;
std::vector<uint64_t> m_ticks;
} m_command_pool;
CommandBuffer m_command;
std::queue<PendingOperation> m_pending_operations, m_priority_operations;
std::jthread m_priority_thread;
};
There is no fixed eight-buffer ring and no per-command-buffer fence now. The pool
searches for a buffer whose recorded tick is no newer than the GPU's known completed tick;
if none is free, it allocates four more. Every queue submission signals
MasterSemaphore with the next monotonic tick. Deferred destruction records the
current tick and runs only after MasterSemaphore::IsFree says the GPU passed it.
The separate priority thread exists because some deferred work — a readback the guest is blocking on — cannot wait for the normal drain without deadlocking.
What happens on one draw
Following the real call chain:
pm4Handlers.cpp — unpack index_count and flags from the packet body into a DrawAutoArgs.graphicsRun.cpp — apply predication, resolve the current register state, forward to the executor. (The argument list was collapsed into one struct in September 2026, which also fixed the PS5 NGG vertex/instance offsets.)renderDraw.cpp — set debug info, build a DrawCallInfo.renderDraw.cpp — phases ResolveRenderColorTarget, ResolveRenderDepthTarget, then GetGraphicsPrograms: the pipeline cache translates on a miss, materialises the resource snapshot, and compiles the specialization if it is new.renderDraw.cpp — phases PrepareBindings, PrepareVertexBuffers, CreatePipeline, BeginRendering, then set the dynamic state, bind and record vkCmdDraw; DrawComplete closes the log.Each phase name is logged via LogDrawPhase, so a crash or hang inside a draw can be localised from the log alone. Unsupported primitive topologies are now skipped with a log line rather than aborting the draw.
Texture cache internals worth knowing
enum class BindingType : uint8_t { Texture, Storage, RenderTarget, DepthTarget, VideoOut };
ImageId FindImage(ImageDesc& desc, bool exact_format = false);
ImageId FindImageFromRange(uint64_t address, uint64_t size, bool ensure_valid = true);
vk::ImageView FindTexture(ImageId id, const ImageDesc& desc);
void MarkGpuWritten(ImageId id);
void InvalidateMemoryFromGPU(uint64_t address, uint64_t size);
void RunGarbageCollector();
Aliasing is the routine case, not the exception — games reuse memory aggressively, so two images legitimately overlap:
std::vector<ImageId> FindImagesInRegion(uint64_t address, uint64_t size, bool page_overlap) const;
OverlapResult ResolveOverlap(const ImageInfo& requested, BindingType binding,
ImageId cached, ImageId merged);
ImageId ResolveDepthOverlap(const ImageInfo& requested, BindingType binding, ImageId cached);
ImageId ExpandImage(const ImageInfo& info, ImageId source);
void AssociateStencil(ImageId depth, GuestRange stencil);
And memory pressure is managed with explicit thresholds:
uint64_t m_total_used_memory = 0; uint64_t m_trigger_gc_memory = 0; uint64_t m_pressure_gc_memory = 1536ull * 1024 * 1024; // 1.5 GB uint64_t m_critical_gc_memory = 3ull * 1024 * 1024 * 1024; // 3 GB Common::LeastRecentlyUsedCache<ImageId, uint64_t> m_lru_cache;
- TutorialVulkan GuideIts chapters on descriptor sets, pipelines and the render loop map onto
DescriptorHeap,PipelineCacheandCommandScheduler. The fastest way to understand why those classes are shaped as they are. - DocsKhronos Vulkan Guide — SynchronizationFences, semaphores, events and pipeline barriers. This is the part of Vulkan most easily got subtly wrong, and it is exactly what
MasterSemaphoreand the fence-retention helpers are managing. Khronos also has Understanding Vulkan Synchronization as a gentler introduction. - CodeVulkanMemoryAllocatorThe allocator Kyty links. Its documentation explains why raw
vkAllocateMemoryis unusable directly and what the sub-allocation strategies cost.
Guest memory holds swizzled data; Vulkan wants something else. tile.cpp describes guest layouts and tiler.cpp orchestrates conversion using compute shaders.
The layout families
enum class TileBlockFamily : uint32_t {
Standard256B, // small textures
Standard4KB,
Standard4KB3D,
Standard64KB, // the common case for large textures
Standard64KB3D,
Prt64KB, // partially-resident (sparse) textures
Prt64KB3D,
RenderTarget64KB, // colour attachments
Depth64KB, // depth/stencil, with its own interleaving
Count,
};
Each family has a different swizzle, and the parameters change with bytes-per-element and with mip level. Small mip levels share a packed mip tail whose offsets still follow the tiled layout:
struct TileSurfaceDescription {
Prospero::BufferFormat format = Prospero::BufferFormat::kInvalid;
Prospero::TileMode tile_mode = Prospero::TileMode::kLinear;
TileSurfaceDimension dimension = TileSurfaceDimension::Dim2D;
uint32_t width = 0;
uint32_t height = 0;
uint32_t depth = 1;
uint32_t levels = 1;
uint32_t layers = 1;
};
// Width/height fields are measured in addressable elements, not texture texels.
struct TileMipLayout {
uint64_t offset = 0;
uint64_t size = 0;
uint32_t width = 0;
uint32_t height = 0;
uint32_t padded_width = 0;
uint32_t padded_height = 0;
uint32_t tail_x = 0;
uint32_t tail_y = 0;
bool operator==(const TileMipLayout&) const = default;
};
struct TileSurfaceLayout {
TileSurfaceDescription description;
TileTextureBlockLayout texture;
uint32_t first_tail_level = 0;
uint64_t block_slice_size = 0;
uint64_t total_size = 0;
TileMipLayout mips[16] = {};
};
The API
bool TileGetBlockLayout(TileBlockFamily family, uint32_t bytes_per_element,
TileBlockLayout& layout);
bool TileGetTextureElementLayout(Prospero::BufferFormat format, TileTextureElementLayout& layout);
bool TileGetTextureBlockLayout(Prospero::BufferFormat format, Prospero::TileMode tile, bool volume,
TileTextureBlockLayout& layout);
bool TileGetTiledTextureLayout(const TileSurfaceDescription& description,
TileSurfaceLayout& layout);
bool TileGetBlockOffset(const TileBlockLayout& layout, uint32_t x, uint32_t y, uint32_t z,
uint32_t& byte_offset);
bool TileGetBlockXor(const TileBlockLayout& layout, uint32_t block_x, uint32_t block_y,
uint32_t& byte_offset);
bool TileGetBlockXor(const TileBlockLayout& layout, uint32_t block_x, uint32_t block_y,
uint32_t block_z, uint32_t& byte_offset);
bool TileGetDepthSize(uint32_t width, uint32_t height, uint32_t pitch,
Prospero::DepthFormat z_format, Prospero::StencilFormat stencil_format,
bool htile, TileSizeAlign& stencil_size, TileSizeAlign& htile_size,
TileSizeAlign& depth_size, uint32_t num_fragments_log2 = 0);
uint32_t TileGetRenderTargetPitch(uint32_t width, uint32_t bytes_per_element,
uint32_t num_fragments_log2 = 0);
uint32_t TileGetDepthPitch(uint32_t width, uint32_t bytes_per_element,
uint32_t num_fragments_log2 = 0);
bool TileGetRenderTargetSize(uint32_t width, uint32_t height, uint32_t pitch,
uint32_t bytes_per_element, TileSizeAlign& total_size,
uint32_t num_fragments_log2 = 0);
bool TileGetDccSize(uint32_t width, uint32_t height, uint32_t slices,
uint32_t bytes_per_element, uint32_t levels, Prospero::TileMode tile,
TileSizeAlign& total_size, uint32_t num_fragments_log2 = 0);
bool TileGetCmaskSize(uint32_t width, uint32_t height, uint32_t slices, uint32_t levels,
TileSizeAlign& total_size);
void TileGetTextureSize(Prospero::BufferFormat format, uint32_t width, uint32_t height,
uint32_t levels, Prospero::TileMode tile, TileSizeAlign* total_size,
TileSizeOffset* level_sizes, TilePaddedSize* padded_size);
void TileGetTextureTotalSize(Prospero::BufferFormat format, uint32_t width, uint32_t height,
uint32_t depth, uint32_t levels, Prospero::TileMode tile,
bool volume_texture, TileSizeAlign& total_size);
uint32_t TileGetTexturePitch(Prospero::BufferFormat format, uint32_t width,
Prospero::TileMode tile);
TileGetBlockXor deserves a note. Beyond the in-block interleave, AMD applies an XOR of block coordinates into the address so that adjacent blocks land in different memory channels and banks. Miss it and images come out looking like a shuffled checkerboard rather than obviously wrong.
Conversion runs on the GPU
Detiling a 4K texture on the CPU would be unacceptable, so Kyty ships a compute shader per layout family. They are compiled at build time and embedded into the binary (embed_spirv.cmake):
src/graphics/host_gpu/shaders/ ├─ gpu_tiler_common.inc shared helpers ├─ gpu_tiler_standard.inc shared 256B/4KB logic ├─ gpu_tiler_standard4.comp standard 4 KB ├─ gpu_tiler_standard64.comp standard 64 KB ├─ gpu_tiler_standard256.comp standard 256 B ├─ gpu_tiler_standard4_3d.comp 3D variants ├─ gpu_tiler_standard64_3d.comp ├─ gpu_tiler_prt.comp partially-resident ├─ gpu_tiler_prt_3d.comp ├─ gpu_tiler_render_target.comp colour attachments ├─ gpu_tiler_depth.comp depth/stencil ├─ gpu_tiler_promote_d16.comp 16-bit depth → 32-bit ├─ gpu_tiler_demote_d16.comp and back └─ gpu_tiler_color_transform.comp channel order fix-ups
The last three are not tiling at all — they are format adaptations for cases where the host GPU cannot represent a guest format directly. promote_d16 / demote_d16 exist because some drivers handle 16-bit depth poorly, so Kyty may store it as 32-bit and convert on the way in and out.
Compression metadata
| Surface | What it is | Kyty's approach |
|---|---|---|
| HTile | Per-tile depth range and compression state alongside a depth buffer, used for fast rejection and fast clears. | Sizes are computed by TileGetHtileSize; clear state is tracked so a "cleared" surface need not be materialised. |
| DCC | Delta Colour Compression metadata for render targets. | The address comes from the T#'s MetaAddr(). The texture cache tracks meta surfaces and their clear masks (IsMeta, IsMetaCleared, ClearMeta, TouchMeta). |
| CMask / FMask | Fast-clear mask and MSAA fragment mask. | Addresses are carried in HW::RenderTarget; used to detect fast-clear-only targets. |
A "cleared" render target on real hardware may contain no pixel data at all — only a metadata surface saying "everything is this colour." If Kyty ignored that and read the pixels, it would get stale garbage. Tracking clear state is therefore not an optimisation but a correctness requirement.
A game does not "present a frame." It registers buffers, renders into one, and asks the display hardware to scan it out at the next vertical blank. Reproducing that protocol faithfully matters more than it sounds.
The protocol
int VideoOutOpen(int user_id, int bus_type, int index, const void* param);
int VideoOutRegisterBuffers2(int handle, int set_index, int buffer_index_start,
const VideoOutBuffers* buffers, int buffer_num,
const VideoOutBufferAttribute2* attribute, int category, void* option);
int VideoOutSubmitFlip(int handle, int index, int flip_mode, int64_t flip_arg);
int VideoOutIsFlipPending(int handle);
int VideoOutGetFlipStatus(int handle, VideoOutFlipStatus* status);
int VideoOutAddVblankEvent(EventQueue::KernelEqueue eq, int handle, void* udata);
int VideoOutAddFlipEvent(EventQueue::KernelEqueue eq, int handle, void* udata);
int VideoOutWaitVblank(int handle);
Step by step:
- Register. The game hands over guest addresses plus a tiling and format description. Kyty records them; the texture cache creates images lazily.
- Render. Ordinary draws into one of those buffers, through the normal command stream.
- Flip.
VideoOutSubmitFlipis queued, not immediate. A flip submitted while another is pending must block, exactly as on hardware — games rely on this for pacing. - Notify. Flip and vertical-blank events are delivered into the game's event queues.
The GPU-side flip
The interesting case is a flip requested from inside the command stream, which must happen after the preceding draws complete:
class VideoOutDriver final {
public:
int SubmitFlipFromGpu(Graphics::CommandBuffer& buffer, int handle, int index,
int flip_mode, int64_t flip_arg, uint64_t& request_id);
void PrepareFlip(uint64_t request_id, Graphics::CommandBuffer& buffer);
void CompleteFlip(uint64_t request_id);
void SubmitFlipPreparation(uint64_t request_id);
void WaitForSubmitSlot();
void WaitFlipDone(int handle, int index);
};
It becomes a Vulkan submission ordered by timeline semaphore, and the command processor has matching entry points — Flip(), FlipWithInterrupt(), PrepareCpuFlip(), WaitFlipDone().
Virtual vertical blank
A timer fires at the configured rate — 60 Hz by default, --vblank-frequency — and pushes events into every queue that registered for them. This must stay steady even when rendering is slow, because many engines drive their entire simulation from it. A game that waits on vblank and gets it late does not just render late; it simulates late.
The final blit
Only at the very end does the guest's buffer reach the host window. The swapchain path is ordinary Vulkan: acquire an image, blit or resolve the guest buffer into it, present. swapchain.cpp handles format negotiation, present modes and resize.
Presentation statistics and the window thread
Presenter::Impl::Present() increments WindowLoopState::presented_frames after a successful presentation path. WindowContext::Run() samples that atomic counter on the main thread at roughly one-second intervals, computes FPS from the frame-count difference and elapsed SDL milliseconds, and calls UpdateTitle(frame_num, current_fps). HostInputWaitEvent(event, max_wait_ms) bounds the input wait so title updates also happen when no input arrives.
Follow src/graphics/presentation/window/swapchain.cpp, window/window.cpp and window/hostInput.cpp. The title uses SDL_SetWindowTitle() directly on the main thread; presentation does not wait for a title callback. The relaxed frame counter supplies display statistics, not a Vulkan completion fence. C4 explains the ordering distinction.
| File | Role |
|---|---|
presentation/videoOut.cpp | The whole flip model, buffer sets, attributes, events, output modes, colour settings. |
presentation/window/vulkanWindow.cpp | Instance and device creation, extension and feature selection (mesh shaders, atomics, depth features), --gpu device choice, queue setup. |
presentation/window/swapchain.cpp | Swapchain lifecycle, format selection, present; waits for image acquisition before the layout transition. |
presentation/window/window.cpp + hostInput.cpp | SDL3 window and the main-thread event loop; keyboard, mouse, touchpad emulation and gamepad handling. |
presentation/systemOverlay.cpp | The imgui overlay drawn over the game: the IME keyboard and system error dialogs, which stay open until dismissed and keep controller focus. |
presentation/renderDoc.cpp | Loads the RenderDoc API at runtime for frame capture (--rd); captures start and stop on the present thread so a capture is a whole guest frame. |
Blend mapping and readback lifetimes
PipelineStaticParameters is 116 bytes. Depth-bounds enable and range are dynamic state. Inactive blend fields are canonicalized, and identical color/alpha equations share a key. Logical-alpha handling belongs to ShaderPixelInputInfo::alpha_blend_source and the effective blend factors, with None, SourceAlpha, SourceAlphaOne and SourceAlphaZero variants. blendMapping.cpp classifies supported remaps; unsupported combinations disable blending with a warning. Sampler LOD bias is clamped to the host limit.
The 64 MiB download ring is the common readback path, not a hard transfer limit. Oversized transfers allocate a temporary download Buffer; the deferred completion operation owns it until GPU completion, then invalidates host caches and copies back. See BufferCache::DownloadBufferMemory() and CommandScheduler::DeferPriorityOperation().
And how a finished frame actually reaches the screen.
Compute the byte offset of a texel under both layouts. Note that with a narrow texture the two agree — tiling only pays off once a row exceeds a cache line, which is why the default here is wide.
Change render state and watch the pipeline cache key move. Every distinct key is a pipeline compile, and a key that includes state which cannot affect the result is where first-encounter stutter comes from.
And the synchronisation: timeline semaphores ordering work that the CPU and GPU are both racing through.
If you have RenderDoc set up, this is the topic where a capture pays for itself.
Checkpoint