"""Vulkan initialisation and swapchain lifecycle helpers for Engine."""
import dataclasses
import logging
from typing import TYPE_CHECKING, Any
import vulkan as vk
from .gpu.commands import CommandContext
from .gpu.device import create_logical_device, select_physical_device
from .gpu.instance import create_debug_messenger, create_instance
from .gpu.memory import create_image
from .gpu.pipeline import PipelineSpec, build_pipeline, create_shader_module
from .gpu.swapchain import Swapchain
from .gpu.sync import FrameSync
from .gpu.timestamp_pool import TimestampPool
from .materials.shader_compiler import compile_shader
from .renderer.passes import create_render_pass
from .types import FRAMES_IN_FLIGHT, SHADER_DIR
if TYPE_CHECKING:
from .engine import Engine
__all__ = [
"init_vulkan",
"create_instance_and_surface",
"create_device_objects",
"create_depth_resources",
"destroy_depth_resources",
"create_framebuffers",
"destroy_framebuffers",
"recreate_swapchain",
]
log = logging.getLogger(__name__)
[docs]
def init_vulkan(engine: Engine, use_triangle: bool = True) -> None:
"""Initialise Vulkan instance, device, swapchain, render pass, and command buffers.
Thin orchestration over the two reusable halves so the create/destroy seam
is symmetric for device-loss recovery: ``create_instance_and_surface`` makes
the instance-level handles that survive a device-only loss; ``create_device_objects``
makes everything bound to the logical device (recreated on a rebuild).
"""
create_instance_and_surface(engine)
create_device_objects(engine, use_triangle)
[docs]
def create_instance_and_surface(engine: Engine) -> None:
"""Create the instance-level handles (instance, debug messenger, surface).
These survive a device-only loss: on recovery we keep the window + surface
and rebuild only the device objects.
"""
extensions = engine._window.get_required_instance_extensions()
engine._instance, has_validation = create_instance("SimVX", extensions, validation=True)
engine._debug_messenger = create_debug_messenger(engine._instance) if has_validation else None
engine._surface = engine._window.create_graphics_surface(engine._instance)
[docs]
def create_device_objects(engine: Engine, use_triangle: bool = True) -> None:
"""Create everything bound to the logical device (recreated on a rebuild).
Selects the physical device, creates the logical device, swapchain, render
pass, depth + framebuffers, command context, multi-GPU manager, GPU context,
async-compute scheduler, frame sync, and timestamp pools. Requires
``create_instance_and_surface`` to have run first (reads ``engine._surface``).
"""
engine._physical_device, qf = select_physical_device(engine._instance, engine._surface)
engine._graphics_qf = qf.graphics
# Probe the unified capability snapshot once, before creating the logical
# device. Everything (feature requests, draw path, future threading/queue/
# multi-GPU paths) reads from this single object.
from .gpu.capabilities import RenderCapabilities
engine._capabilities = RenderCapabilities.probe(engine._instance, engine._physical_device)
engine._has_mdi = engine._capabilities.multi_draw_indirect
if not engine._has_mdi:
log.info("multiDrawIndirect not supported: using individual draw calls")
# On a multi-queue GPU (the rig: dedicated COMPUTE-without-GRAPHICS family)
# create a queue on the dedicated compute (and transfer) family so the
# async-compute scheduler can submit compute passes off the graphics queue.
# On this dev box both are None -> exactly graphics+present as before.
caps = engine._capabilities
(
engine._device,
engine._graphics_queue,
engine._present_queue,
engine._compute_queue,
engine._transfer_queue,
) = create_logical_device(
engine._physical_device, qf,
compute_qf=caps.dedicated_compute_qf,
transfer_qf=caps.dedicated_transfer_qf,
)
# Warm a persistent pipeline cache before any pipeline is built, so the first
# frame's vkCreate*Pipelines reuse the driver's saved binaries instead of
# recompiling from SPIR-V every launch (the dominant desktop startup cost).
from .gpu.pipeline_cache import init_pipeline_cache
init_pipeline_cache(engine._device, engine._physical_device)
# Load swapchain-related KHR functions
engine._vk_acquire = vk.vkGetInstanceProcAddr(engine._instance, "vkAcquireNextImageKHR")
engine._vk_present = vk.vkGetInstanceProcAddr(engine._instance, "vkQueuePresentKHR")
# Use framebuffer (physical) size for swapchain: may differ from logical
# window size on HiDPI displays (e.g. 3200x1800 vs 1600x900 at 200%)
fb_size = engine._window.get_framebuffer_size() if engine._window else (engine.width, engine.height)
engine._swapchain = Swapchain(
engine._instance,
engine._device,
engine._physical_device,
engine._surface,
fb_size,
qf.graphics,
qf.present,
vsync=engine._vsync,
)
engine._swapchain.create()
depth_fmt = vk.VK_FORMAT_D32_SFLOAT if engine._use_depth else 0
engine._render_pass = create_render_pass(engine._device, engine._swapchain.image_format, depth_fmt)
if engine._use_depth:
create_depth_resources(engine)
create_framebuffers(engine)
if use_triangle:
# Compile and load triangle shaders (built-in demo)
vert_spv = compile_shader(SHADER_DIR / "triangle.vert")
frag_spv = compile_shader(SHADER_DIR / "triangle.frag")
engine._vert_module = create_shader_module(engine._device, vert_spv)
engine._frag_module = create_shader_module(engine._device, frag_spv)
# Built-in triangle: shader-generated geometry (empty vertex input),
# no descriptor sets / push constants, default opaque state.
engine._pipeline, engine._pipeline_layout = build_pipeline(
engine._device,
PipelineSpec(name="Triangle"),
engine._render_pass,
engine._swapchain.extent,
vert_module=engine._vert_module,
frag_module=engine._frag_module,
)
engine._cmd_ctx = CommandContext(engine._device, qf.graphics)
engine._cmd_ctx.create_pool()
engine._cmd_buffers = engine._cmd_ctx.allocate(FRAMES_IN_FLIGHT)
# Explicit-multi-adapter manager. OFF unless the App opted in
# (``engine._multi_gpu_requested``) AND > 1 physical device is present, in
# which case it creates an independent logical VkDevice per physical GPU.
# Otherwise it is a single-slot passthrough wrapping the device just created:
# ``multi_gpu`` False, ``device_count`` 1, no behaviour change (this box). The
# primary slot reuses the engine's device (never re-created); the manager only
# owns + destroys any secondaries it creates.
from .gpu.device import _find_queue_families
from .gpu.multi_device import MultiDeviceManager
def _resolve_secondary_qf(pd: Any) -> Any:
# Secondaries render offscreen and never present, but reuse the same
# surface-aware resolver as the primary for parity on the rig. The
# surface belongs to the primary; a secondary that reports no graphics+
# present pair here is skipped by the manager rather than guessed.
return _find_queue_families(
pd, engine._surface,
vk.vkGetInstanceProcAddr(engine._instance, "vkGetPhysicalDeviceSurfaceSupportKHR"),
)
engine._multi_device = MultiDeviceManager(
primary_physical_device=engine._physical_device,
primary_queue_families=qf,
primary_device=engine._device,
primary_graphics_queue=engine._graphics_queue,
primary_present_queue=engine._present_queue,
primary_compute_queue=engine._compute_queue,
primary_transfer_queue=engine._transfer_queue,
physical_devices=list(vk.vkEnumeratePhysicalDevices(engine._instance)),
enabled=getattr(engine, "_multi_gpu_requested", False),
capabilities=caps,
find_queue_families=_resolve_secondary_qf,
)
# Reflect whether the secondaries enabled VK_KHR_external_memory_fd back into
# the immutable capability snapshot so the cross-device transfer selector
# gates dma-buf on *enabled* (not merely probed). On the single-GPU / unopted
# path the manager created no secondaries, so this stays False (staging floor).
if engine._multi_device.external_memory_fd_enabled and not caps.external_memory_fd_enabled:
caps = dataclasses.replace(caps, external_memory_fd_enabled=True)
engine._capabilities = caps
# Build the shared GPU context now that all handles exist
from .gpu.context import GPUContext
engine._ctx = GPUContext(
device=engine._device,
physical_device=engine._physical_device,
graphics_queue=engine._graphics_queue,
present_queue=engine._present_queue,
graphics_qf=engine._graphics_qf,
cmd_ctx=engine._cmd_ctx,
compute_queue=engine._compute_queue,
transfer_queue=engine._transfer_queue,
compute_qf=caps.dedicated_compute_qf if engine._compute_queue is not None else None,
transfer_qf=caps.dedicated_transfer_qf if engine._transfer_queue is not None else None,
multi_gpu=engine._multi_device.multi_gpu,
device_count=engine._multi_device.device_count,
)
# Async-compute scheduler: records compute passes into a dedicated
# compute command buffer + submits on the compute queue (multi-queue GPU),
# else a passthrough that records into the primary graphics cmd (this box).
from .renderer.async_compute import AsyncComputeScheduler
engine._async_compute = AsyncComputeScheduler(engine._ctx, FRAMES_IN_FLIGHT)
engine._sync = FrameSync(engine._device, len(engine._swapchain.images))
engine._sync.create()
# Per-frame-in-flight timestamp pool: used by the renderer to wrap each
# pass with vkCmdWriteTimestamp pairs. Latest readback lands on
# ``engine.gpu_phase_times`` (label -> ms) for downstream profilers.
engine._timestamp_pools = []
try:
for _ in range(FRAMES_IN_FLIGHT):
engine._timestamp_pools.append(
TimestampPool(engine._device, engine._physical_device, max_labels=64),
)
except Exception as exc: # noqa: BLE001 (any failure means no GPU timing).
log.warning("TimestampPool unavailable, GPU phase timings disabled: %s", exc)
engine._timestamp_pools = []
[docs]
def create_depth_resources(engine: Engine) -> None:
"""Create depth buffer image, memory, and view."""
w, h = engine._swapchain.extent
engine._depth_image, engine._depth_memory = create_image(
engine._device,
engine._physical_device,
w, h,
vk.VK_FORMAT_D32_SFLOAT,
vk.VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,
)
view_info = vk.VkImageViewCreateInfo(
image=engine._depth_image,
viewType=vk.VK_IMAGE_VIEW_TYPE_2D,
format=vk.VK_FORMAT_D32_SFLOAT,
subresourceRange=vk.VkImageSubresourceRange(
aspectMask=vk.VK_IMAGE_ASPECT_DEPTH_BIT,
baseMipLevel=0,
levelCount=1,
baseArrayLayer=0,
layerCount=1,
),
)
engine._depth_view = vk.vkCreateImageView(engine._device, view_info, None)
[docs]
def destroy_depth_resources(engine: Engine) -> None:
"""Destroy depth buffer resources."""
if engine._depth_view:
vk.vkDestroyImageView(engine._device, engine._depth_view, None)
engine._depth_view = None
if engine._depth_image:
vk.vkDestroyImage(engine._device, engine._depth_image, None)
engine._depth_image = None
if engine._depth_memory:
vk.vkFreeMemory(engine._device, engine._depth_memory, None)
engine._depth_memory = None
[docs]
def create_framebuffers(engine: Engine) -> None:
"""Create framebuffers for each swapchain image."""
engine._framebuffers = []
for view in engine._swapchain.image_views:
attachments: list[Any] = [view]
if engine._use_depth and engine._depth_view:
attachments.append(engine._depth_view)
fb_info = vk.VkFramebufferCreateInfo(
renderPass=engine._render_pass,
attachmentCount=len(attachments),
pAttachments=attachments,
width=engine._swapchain.extent[0],
height=engine._swapchain.extent[1],
layers=1,
)
engine._framebuffers.append(vk.vkCreateFramebuffer(engine._device, fb_info, None))
[docs]
def destroy_framebuffers(engine: Engine) -> None:
"""Destroy all swapchain framebuffers."""
for fb in engine._framebuffers:
vk.vkDestroyFramebuffer(engine._device, fb, None)
engine._framebuffers.clear()
[docs]
def recreate_swapchain(engine: Engine, *, vsync: bool | None = None) -> None:
"""Recreate swapchain, depth buffer, and framebuffers after resize.
Pass ``vsync`` to also switch present mode in the rebuild: used by
:meth:`Engine.set_vsync` to apply a runtime toggle without relaunch.
"""
w, h = engine._window.get_framebuffer_size()
while w == 0 or h == 0:
engine._window.poll_events()
w, h = engine._window.get_framebuffer_size()
if vsync is not None:
engine._vsync = vsync
vk.vkDeviceWaitIdle(engine._device)
# Reset the per-frame command pool so already-recorded command buffers drop
# their references to the buffers/images that ``renderer.resize`` is about to
# destroy (HDR/post-process targets). Without this the executable-state
# command buffers still reference those buffers, tripping
# VUID-vkDestroyBuffer-buffer-00922 during the first startup resize.
if engine._cmd_ctx and engine._cmd_ctx.pool:
vk.vkResetCommandPool(engine._device, engine._cmd_ctx.pool, 0)
destroy_framebuffers(engine)
if engine._use_depth:
destroy_depth_resources(engine)
engine._sync.destroy()
engine._swapchain.recreate((w, h), vsync=vsync)
engine.width, engine.height = w, h
if engine._use_depth:
create_depth_resources(engine)
create_framebuffers(engine)
engine._sync = FrameSync(engine._device, len(engine._swapchain.images))
engine._sync.create()
if engine._pick_pass:
engine._pick_pass.resize(engine._swapchain.extent)
# Resize the forward renderer (post-processing targets, SSAO, fog, etc.)
if hasattr(engine, '_renderer') and engine._renderer and hasattr(engine._renderer, 'resize'):
engine._renderer.resize(w, h)