simvx.graphics.gpu.secondary_engine

Secondary-engine facade for the explicit-multi-adapter (multi-GPU) renderer.

The default :class:~simvx.graphics.renderer.forward.Renderer and every resource manager it owns (PipelineManager / BufferManager / PassOrchestrator / SceneContentRenderer / mesh_registry / the texture system) read their GPU handles off one engine object: engine.ctx.device, engine.mesh_registry, engine.texture_manager and so on. A VkPipeline / VkBuffer / descriptor created on one VkDevice cannot be used on another, so to render an offloaded SubViewport SRU on a secondary GPU we need a second, fully independent renderer whose resources live on the secondary device.

Option A (the one implemented here) is to duplicate the renderer via an engine facade: rather than refactor the renderer to take a device, we give it a lightweight object that quacks exactly like the bits of Engine the renderer reads, but backed by a secondary :class:~.multi_device.DeviceSlot. Then Renderer(facade) + facade.attach_renderer() + renderer.setup() builds all GPU resources on the secondary device, reusing every line of existing renderer code unchanged. Nothing on the single-device path constructs a facade, so that path is unchanged.

Engine attribute surface the renderer + its managers + the passes + scene_adapter read off the engine object (grep-derived from forward.py / pipeline_manager.py / buffer_manager.py / pass_orchestrator.py / scene_renderer.py / mesh_registry.py / scene_adapter.py):

========================== ================================================ attribute how the facade provides it ========================== ================================================ ctx a secondary :class:~.context.GPUContext (device + queues + a CommandContext on the secondary device) ctx.device secondary VkDevice (the single most-read handle) ctx.physical_device secondary VkPhysicalDevice ctx.graphics_queue secondary graphics queue ctx.command_pool secondary command pool (via cmd_ctx) mesh_registry per-device :class:~..renderer.mesh_registry.MeshRegistry texture_manager per-device :class:~..materials.texture.TextureManager (the facade itself is the registrar) capabilities the shared probed snapshot (read-only facts) _has_mdi mirrored from capabilities shader_dir shared SHADER_DIR (SPIR-V is device-independent) content_scale mirrored from the primary engine (SRU sizing) extent the offscreen SRU size (no swapchain on a secondary) render_pass a secondary offscreen-compatible VkRenderPass push_constants records vkCmdPushConstants (device-independent) register_texture / bindless texture registrar methods, recorded on the upload_texture_pixels / secondary device’s descriptor set update_texture / unregister_texture _texture_pixel_generations per-slot count of the times a bound slot was rebound under its readers (the positional shadow cache compares it to know a cached row went stale) retire a :class:~.retire.RetireQueue, drained by the SRU recorder rather than clocked on frames (a secondary has no FrameSync; every submission ends in vkQueueWaitIdle) texture_descriptor_set secondary bindless descriptor set texture_descriptor_layout secondary bindless descriptor layout current_timestamp_pool None (per-pass GPU timing is optional) renderer the secondary :class:Renderer once attached capture_frame raises (a secondary never owns the swapchain) _window / _sync None (a secondary never presents) ========================== ================================================

Scope: this module builds the facade and the per-device residency helpers, and is covered by GPU-free unit tests asserting the full attribute surface. The Renderer(facade).setup() GPU build and the offload-record-and-composite loop require a system exposing two or more Vulkan devices and are not exercised by the test suite. Textured-SRU residency mirrors the bindless uploads the SRU samples; a no-texture / vertex-colour SRU is the simplest case and needs no texture mirroring (see :class:SecondaryResidency).

Module Contents

Classes

SecondaryRenderContext

Engine-like facade backed by a secondary :class:DeviceSlot.

SecondaryResidency

Mirrors to a secondary device the meshes + textures an offloaded SRU needs.

Data

API

simvx.graphics.gpu.secondary_engine.log

‘getLogger(…)’

simvx.graphics.gpu.secondary_engine.__all__

[‘SecondaryRenderContext’, ‘SecondaryResidency’]

class simvx.graphics.gpu.secondary_engine.SecondaryRenderContext(slot: simvx.graphics.gpu.multi_device.DeviceSlot, *, capabilities: simvx.graphics.gpu.capabilities.RenderCapabilities | None, content_scale: tuple[float, float] = (1.0, 1.0), extent: tuple[int, int] | None = None, render_pass: Any = None, max_textures: int = 4096)

Engine-like facade backed by a secondary :class:DeviceSlot.

Exposes exactly the attribute surface the renderer + its managers + the passes

  • scene_adapter read off Engine (see the module docstring table), but every GPU handle is the secondary device’s. Renderer(facade) therefore builds all its pipelines / buffers / descriptors on the secondary device, reusing the existing renderer code unchanged.

Construction does not touch the primary device: it only reads scalar facts (content_scale, capabilities) from the primary engine and creates a CommandContext + a :class:GPUContext on the secondary slot’s device. The expensive GPU build (pipelines, descriptor pools, SSBOs) happens later in Renderer(facade).setup().

Initialization

property ctx: simvx.graphics.gpu.context.GPUContext
property retire: simvx.graphics.gpu.retire.RetireQueue

Deferred frees on the secondary device. See the module table.

property capabilities: simvx.graphics.gpu.capabilities.RenderCapabilities | None
property content_scale: tuple[float, float]
property extent: tuple[int, int] | None
property shader_dir: Any
property render_pass: Any
property current_timestamp_pool: Any
property mesh_registry: simvx.graphics.renderer.mesh_registry.MeshRegistry

Per-device mesh registry (lazy), mirroring Engine.mesh_registry.

property texture_manager: Any

Per-device bindless texture manager (lazy), mirroring Engine.texture_manager.

The facade is its own registrar (it implements register_texture / upload_texture_pixels on the secondary device), so the manager caches

  • resolves against secondary-device bindless slots.

property renderer: Any

The secondary :class:Renderer once bound via :meth:attach_renderer.

Unlike Engine.renderer this does NOT lazily create a renderer: the offload coordinator constructs + sets up the secondary renderer explicitly (it is a heavy, multi-device-only GPU build) and binds it here.

attach_renderer(renderer: Any) None

Bind the secondary :class:Renderer built against this facade.

ensure_command_pool() Any

Create the secondary device’s command pool on first use, return it.

Construction is GPU-free, so the pool is built here the first time the offload path needs to record on the secondary (the secondary Renderer build + every one-shot layout transition read ctx.command_pool). Reached only on a multi-device run (no facade is constructed otherwise).

ensure_offscreen_render_pass() Any

Create + own an SRU-compatible offscreen render pass on first use.

The secondary Renderer’s pipelines are built (in setup()) against facade.render_pass, so it MUST exist BEFORE the renderer factory runs, and MUST be format-compatible with the per-SRU RenderTarget created later (R16G16B16A16_SFLOAT colour + D32 depth, samplable). Pipelines use dynamic viewport/scissor, so the placeholder extent does not bind them to a size and the same pipelines serve any SRU size. Reached only on a multi-device run (no facade is constructed otherwise).

property texture_descriptor_layout: Any
property texture_descriptor_set: Any
register_texture(image_view: Any, *, filter: str = 'linear', mip_count: int = 1) int

Register a bindless texture on the SECONDARY device. Mirrors Engine.register_texture.

Raises: ValueError: the array is full, i.e. the high-water mark has reached max_textures and no slot has been freed.

upload_texture_pixels(pixels: numpy.ndarray, width: int, height: int, *, filter: str = 'linear', colour_space: str = 'srgb') int

Upload RGBA pixels to the SECONDARY device. Mirrors Engine.upload_texture_pixels.

colour_space ("srgb" default / "linear") selects the sampled view format exactly as on the primary device, so multi-GPU sprite uploads round-trip through the sRGB swapchain identically.

update_texture(slot: int, image_view: Any) None

Rewrite a bindless slot on the SECONDARY device. Mirrors Engine.update_texture.

The slot keeps its index while resolving to another image, so consumers that cached what they drew from it have only the count to go on; it is kept here for the same reason and in the same shape as on Engine.

unregister_texture(slot: int) None

Free a bindless slot, repointing it at the dummy. Mirrors Engine.unregister_texture.

This device’s bindless array carries no UPDATE_AFTER_BIND flag, so the rewrite is legal only when no command buffer submitted on the secondary queue is pending. That holds by construction: every secondary submission ends in the vkQueueWaitIdle inside end_single_time_commands. Call it before destroying the view.

push_constants(cmd: Any, pipeline_layout: Any, data: bytes | bytearray) None

Record vkCmdPushConstants. Identical to Engine.push_constants.

Push constants are recorded into a command buffer, not bound to a device object, so the implementation is device-independent: the secondary’s cmd buffer + its pipeline layout flow in from the secondary renderer.

abstractmethod capture_frame() Any

A secondary never owns the swapchain: capture is a primary-only op.

destroy() None

Free the secondary-device resources this facade owns (not the device).

The secondary VkDevice itself is owned + destroyed by

Class:

~.multi_device.MultiDeviceManager; this only releases the command pool, samplers, descriptor pool, uploaded images, and mesh registry the facade created on that device.

class simvx.graphics.gpu.secondary_engine.SecondaryResidency(facade: simvx.graphics.gpu.secondary_engine.SecondaryRenderContext)

Mirrors to a secondary device the meshes + textures an offloaded SRU needs.

A VkBuffer / image / descriptor created on the primary device cannot be used on a secondary, so an offloaded SRU’s geometry (vertex/index buffers via the per-device :class:~..renderer.mesh_registry.MeshRegistry) and any textures it samples (re-uploaded into the secondary’s bindless table) must be made resident on the secondary device before the SRU is recorded there.

Two cases:

  • No-texture / vertex-colour SRU (the simplest case): only the meshes need mirroring; :meth:ensure_meshes does that and nothing else is required. samples_textures is False.

  • Textured SRU: additionally the bindless uploads the SRU samples must be mirrored via :meth:ensure_textures. This requires the source pixels: the primary’s :class:~..materials.texture.TextureManager retains them only when constructed with retain_pixels=True (the web path) and NOT on desktop, so full desktop textured-SRU residency is FLAGGED as the remaining step (see

    meth:

    ensure_textures). The structure + mesh path are complete and tested.

The mirror is keyed by the same source identity the primary uses (mesh id, texture source key) so a mesh/texture shared across SRUs uploads once per secondary device.

Initialization

ensure_meshes(meshes: list[tuple[int, Any, numpy.ndarray]]) dict[int, Any]

Upload (id, streams, indices) tuples to the secondary mesh registry.

Returns a map primary_mesh_id -> secondary MeshHandle. Meshes already resident (same id) are reused, so a shared mesh uploads once per device. streams (:class:~simvx.graphics.types.VertexStreams) / indices are the same arrays the primary registered; they are device-independent CPU data, re-uploaded into secondary GPU buffers via the per-device

Class:

MeshRegistry.

secondary_mesh(primary_mesh_id: int) Any | None

The secondary :class:MeshHandle for a primary mesh id, or None.

ensure_textures(textures: list[tuple[int, numpy.ndarray, int, int]]) dict[int, int]

Mirror an SRU’s sampled textures into the secondary bindless table.

textures is (primary_tex_id, rgba_pixels, width, height) per source the SRU samples. Each is uploaded to the secondary device’s bindless table (once per device, keyed by primary tex id) and the returned map gives the secondary slot to rewrite material texture ids against.

RESIDENCY GAP (flagged): the desktop TextureManager does NOT retain source pixels (retain_pixels=False), so the caller cannot in general recover rgba_pixels for an arbitrary already-uploaded primary texture. The remaining step for full desktop textured-SRU residency is to capture the source pixels at primary-upload time (or read them back from the primary image) and feed them here. Until then, route only no-texture / vertex-colour SRUs to a secondary (samples_textures False); a textured SRU stays on the primary. This method itself is complete: given the pixels it mirrors correctly, and is unit-tested with a fake uploader.

secondary_texture(primary_tex_id: int) int | None

The secondary bindless slot for a primary tex id, or None.