Source code for simvx.graphics._engine_init

"""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)