simvx.graphics.engine

Top-level engine entry point.

Module Contents

Classes

CubemapHandle

Opaque handle to a GPU cubemap returned by :meth:Engine.load_cubemap.

DeviceState

Lifecycle of the Vulkan logical device, mirroring the web GpuContext.

Engine

Graphics engine: owns the window, GPU context, and render loop.

Data

API

simvx.graphics.engine.__all__

[‘CubemapHandle’, ‘Engine’]

simvx.graphics.engine.log

‘getLogger(…)’

class simvx.graphics.engine.CubemapHandle

Opaque handle to a GPU cubemap returned by :meth:Engine.load_cubemap.

Pass to :meth:~simvx.graphics.renderer.forward.Renderer.set_skybox to install it as the scene skybox / IBL source. The engine takes ownership of the underlying Vulkan resources and destroys them at shutdown, whether or not the handle is ever installed: the engine records every handle it hands out, set_skybox discards the one it claims, and shutdown frees the rest.

Call :meth:release to free one earlier than that. A level that loads a sky it turns out not to need is the case: holding the handle to shutdown holds an image, a view, a sampler and their memory for the life of the process.

view: Any

None

sampler: Any

None

image: Any

None

memory: Any

None

engine: Any

‘field(…)’

extent: tuple[int, int] | None

‘field(…)’

release() None

Free this cubemap’s GPU resources now, rather than at shutdown.

Safe to call more than once, and safe on a handle the renderer has already claimed: both are no-ops, because the engine drops a handle from its record the moment something else becomes responsible for it. The free is deferred through the retire queue, so a frame still sampling the cubemap completes first.

class simvx.graphics.engine.DeviceState

Bases: enum.Enum

Lifecycle of the Vulkan logical device, mirroring the web GpuContext.

BOOTING before the first device is created; READY while rendering; LOST when VK_ERROR_DEVICE_LOST is observed; RECOVERING while a rebuild is in flight; DESTROYED after shutdown. The legacy _device_lost bool remains the per-frame submission gate; this state is the coarser machine the recovery path drives.

BOOTING

‘booting’

READY

‘ready’

LOST

‘lost’

RECOVERING

‘recovering’

DESTROYED

‘destroyed’

__new__(value)
__repr__()
__str__()
__dir__()
__format__(format_spec)
__hash__()
__reduce_ex__(proto)
__deepcopy__(memo)
__copy__()
name()
value()
class simvx.graphics.engine.Engine(width: int = 1280, height: int = 720, title: str = 'SimVX', backend: str | None = None, renderer: str = 'deferred', max_textures: int = MAX_TEXTURES, visible: bool = True, vsync: bool = False, target_fps: int | None = None)

Graphics engine: owns the window, GPU context, and render loop.

Initialization

clear_colour: list[float]

[0.0, 0.0, 0.0, 1.0]

The window background in authored sRGB space, with linear alpha.

Every attachment that clears to this decodes it for its own format, so the value held here is the one the author wrote and not the one the GPU receives. The fourth component is alpha and passes through untouched: the scene adapter and the secondary-device path both read it as the “draw the sky” switch.

property ctx: simvx.graphics.gpu.context.GPUContext | None

GPU context holding device, physical_device, queues, and command pool.

None until run() invokes _init_vulkan().

property retire: simvx.graphics.gpu.retire.RetireQueue

The queue that holds a GPU free until the frames naming it have retired.

Hand a destructor here rather than calling it from anywhere a submitted command buffer might still reference the resource. See

Class:

~.gpu.retire.RetireQueue for what the queue does and does not prove.

property multi_device: Any

The :class:~simvx.graphics.gpu.multi_device.MultiDeviceManager.

None until run() invokes _init_vulkan(). On the single-GPU / unopted path it is a single-slot passthrough (multi_gpu is False).

property render_pass: simvx.graphics.types._VkRenderPass | None

Main render pass. None until run() invokes _init_vulkan().

property extent: tuple[int, int] | None

Swapchain extent. None until run() invokes _init_vulkan().

property content_scale: tuple[float, float]

HiDPI content scale (e.g. (2.0, 2.0) on a 200% display).

property vsync: bool

Whether vertical sync is currently enabled.

set_vsync(value: bool) None

Toggle vsync at runtime by recreating the swapchain with a new present mode.

No-op if the requested state matches the current one. Before the engine has a swapchain (pre-run()) the value is stored and used at boot.

property target_fps: int | None

Frame cap in Hz, or None for uncapped.

Writable while the loop is running: :meth:run recomputes its frame budget from this field every iteration, so a game’s options menu can apply a new cap without a relaunch. Two limits worth knowing: under vsync a cap above the refresh rate does nothing (the budget sleep sits on top of a blocking present), and the externally driven

Meth:

begin / :meth:step / :meth:end path is caller-clocked and ignores the cap by design.

property pre_render_callback: collections.abc.Callable[[simvx.graphics.types._VkCommandBuffer], None] | None

Callback invoked after vkBeginCommandBuffer but before the main render pass.

property shader_dir: pathlib.Path
property current_frame: int

Index of the frame-in-flight slot currently being recorded (0..FRAMES_IN_FLIGHT-1).

Stable across a frame’s recording; advances at present. Per-frame ring buffers index their current slot by this so a write never lands on memory a still-pending previous frame reads.

property current_timestamp_pool: simvx.graphics.gpu.timestamp_pool.TimestampPool | None

Return the TimestampPool for the in-flight frame currently recording.

None if the device does not support timestamp queries (the pool list is left empty by _init_vulkan in that case). Renderers wrap each pass with pool.begin(cmd, label) / pool.end(cmd, label).

property mesh_registry: simvx.graphics.renderer.mesh_registry.MeshRegistry

Get mesh registry (lazy init).

property capabilities: simvx.graphics.gpu.capabilities.RenderCapabilities | None

Immutable host + GPU capability snapshot (set during init_vulkan).

None before Vulkan initialisation. Read-only: configuration flows through WorldEnvironment / App, never through this object.

property texture_manager: simvx.graphics.materials.texture.TextureManager

Get texture manager (lazy init).

property batch: simvx.graphics.renderer.gpu_batch.GPUBatch

Get the GPU batch renderer (lazy init).

property renderer: simvx.graphics.renderer.forward.Renderer

Get the active renderer (creates the default renderer if none exists).

create_renderer(renderer_type: str = 'forward') simvx.graphics.renderer.forward.Renderer

Create and initialize a renderer.

property texture_descriptor_layout: simvx.graphics.types._VkDescriptorSetLayout

Get (lazily init) the texture descriptor set layout.

property texture_descriptor_set: simvx.graphics.types._VkDescriptorSet

Get the texture descriptor set (set 1).

create_render_target(width: int, height: int, use_depth: bool = True) simvx.graphics.renderer.render_target.RenderTarget

Create an offscreen render target for render-to-texture.

load_cubemap(paths_or_hdr: list[str] | str | None = None, *, colour: tuple[float, float, float] | None = None, face_size: int = 256, faces: list | None = None) simvx.graphics.engine.CubemapHandle

Load a cubemap from 6 face images, an equirectangular HDR, or a solid colour.

Args: paths_or_hdr: Either a list of 6 face paths [+X, -X, +Y, -Y, +Z, -Z] for a pre-split cubemap, or a single .hdr (Radiance RGBE) equirectangular file projected onto 6 faces on the CPU. None falls back to colour. colour: RGB triple in 0..1 used when no paths are provided. face_size: Per-face resolution when projecting from an HDR equirect.

Returns: A :class:CubemapHandle; hand this to :meth:Renderer.set_skybox to install it, or :meth:CubemapHandle.release to free it. Either way the engine frees it at device teardown if nothing else has.

update_cubemap_faces(handle: simvx.graphics.engine.CubemapHandle, faces: list[numpy.ndarray]) None

Rewrite the six faces of an existing cubemap in place.

The handle keeps its identity, so the skybox pipeline, the descriptor that names the view and the IBL pass’s own binding all stay valid: the only thing that changes is the texels. That is the whole point. An animated sky changes two colours per frame and nothing about the cube’s dimensions or format, and rebuilding image, memory, view, sampler, SkyboxPass and its pipeline around that is work with no consequence.

faces must be six (h, w, 4) float32 arrays of the same shape as the ones the cube was loaded with. A different shape is refused rather than silently reallocated: the caller holding this handle is the one that would have to hear about a new view, and it cannot.

Waits out the frames in flight first. The cube is a sampled image the submitted-and-unfinished frames may still be reading, so writing it from the host with no wait is a cross-frame write-after-read – the sky would tear or flicker under load and nothing would say why. The wait is one prior frame’s fence rather than a device drain, which is what the rebuild path did.

Raises: ValueError: wrong number of faces, or a face whose shape does not match the cube’s.

register_lut(tex_id: int, lut_data: numpy.ndarray) int

Register a 3D colour-grading LUT under tex_id.

lut_data is an (size, size, size, 4) uint8 array (e.g. from simvx.core.colour_grading.generate_warm_lut). Select it at render time with WorldEnvironment.lut_tex_id = tex_id and lut_enabled = True. The LUT is a 3D rgba8 image sampled post-tonemap, matching the web runtime. Returns tex_id.

Callable from tick-time game code, so the staging copy is recorded from the upload pool with its lock held, exactly as :meth:load_cubemap does. The frame pool belongs to the pipelined render thread while it is recording, and a pool cannot be allocated from and recorded into by two threads at once.

register_texture(image_view: simvx.graphics.types._VkImageView, *, filter: str = 'linear', mip_count: int = 1) int

Register a texture (image view) into the bindless array.

Returns the texture index. Reuses slots freed by

Meth:

unregister_texture; otherwise allocates a new slot from the high-water mark.

filter selects the bound sampler: "linear" (default, bilinear) or "nearest" (point sampling, the right choice for pixel-art sprites). The same image view can be registered twice with different filter modes to give the same texture two bindless indices, which is how :class:SceneAdapter honours per-Sprite2D filter.

mip_count is the number of mip levels in the view. The default 1 binds the shared maxLod=0 sampler (single-mip behaviour unchanged); a value > 1 binds a cached maxLod=mip_count-1 sampler so the whole mip chain is sampled (without it every level beyond 0 is unreachable).

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

update_texture(slot: int, image_view: simvx.graphics.types._VkImageView) None

Rewrite the descriptor at an existing bindless slot.

Used when the backing image view changes (e.g. render-target resize) but the slot id must remain stable so callers that captured it don’t need re-notification.

texture_binding(slot: int) tuple[simvx.graphics.types._VkImageView, simvx.graphics.types._VkSampler] | None

The (image view, sampler) pair a bindless slot resolves to, or None.

Every other pass reads textures through the bindless array by index. The custom-shader material group cannot: its descriptor layout has to match the WebGPU bind-group layout the same shader gets in the browser, where a texture and a sampler are separate bindings. This is the one accessor that hands those two handles back for a slot the texture manager minted.

material_sampler() simvx.graphics.types._VkSampler

The sampler every declared sampler binding of a custom material gets.

One linear, repeating, mip-sampling sampler, and deliberately the same one the browser runtime binds. A custom shader declares its samplers separately from its textures, so a sampler cannot inherit the filter of any one texture it reads; what it can do is be identical on both backends. LOD is left unclamped (Vulkan bounds it by the view’s own level count, as WebGPU’s default lodMaxClamp does), so a mip-chained texture minifies through its chain rather than shimmering off level 0.

unregister_texture(slot: int) None

Release a bindless texture slot, repointing it at the 1x1 dummy texture.

The descriptor is rewritten before the slot goes on the free list, so a material that still holds the index samples a live view rather than a destroyed one. descriptorBindingPartiallyBound excuses a descriptor the shader never reaches; it does not excuse one it still indexes.

Call this before destroying the view. The repoint is legal under the array’s UPDATE_AFTER_BIND flag precisely because both the old and the new value are live at that instant; unregistering a view already destroyed writes nothing dangerous but leaves the window this method exists to close.

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

Upload raw RGBA pixel data to GPU. Returns the bindless texture index.

filter is forwarded to :meth:register_texture; pass "nearest" for pixel-art sprites that should keep crisp edges when scaled.

colour_space selects the sampled image view format, keeping shading in linear space: "srgb" (default) makes a VK_FORMAT_R8G8B8A8_SRGB view so the GPU decodes the stored sRGB bytes to linear on sample, round-tripping correctly through the sRGB swapchain (the right choice for colour textures: 2D sprites, tileset atlases, Draw2D images). "linear" keeps the VK_FORMAT_R8G8B8A8_UNORM view: the choice for data textures that are not perceptual colour (normal/data maps) and for the 3D material albedo path, which currently treats its uploaded bytes as already-linear. The pixel bytes uploaded to memory are identical in both cases; only the view’s interpretation differs.

mipmaps=True allocates the full mip chain and generates every level on the GPU with a linear-filtered vkCmdBlitImage chain, then binds the maxLod=chain-1 sampler so minification actually reaches the chain. Falls back to a single mip when the view format lacks linear-blit support (so exotic formats degrade gracefully) or when the image is already 1x1. The default False keeps the historical single-mip upload byte-identical.

update_texture_pixels(slot: int, pixels: numpy.ndarray, width: int, height: int, *, filter: str | None = None, colour_space: str | None = None, mipmaps: bool | None = None) int

Replace the pixels behind an existing bindless slot. Returns the slot.

The slot id stays valid, so everything already drawing with it – a sprite’s published handle, a material’s texture index – shows the new image without being told. This is what makes simvx.core.graphics.Texture.update() reach the GPU, and it is the call an IDE-style widget re-rasterising into a cached texture wants.

Omitted keywords keep what the slot was registered with. That matters: an update that re-derived the sampler would quietly turn a filter="nearest" pixel-art texture bilinear, and drop a mip chain’s maxLod back to 0.

Same dimensions and format (the common case: a widget redrawing itself) writes straight into the existing image, so nothing is allocated and nothing is freed. A changed size or colour space rebuilds the image behind the slot and releases the old one, rather than leaking it.

That rebuild belongs to the widgets that own both ends of a slot – the IDE minimap re-rasterises at the panel’s width and re-uploads at the new size, keeping its own quad in step. It is not how a node’s texture resizes: Texture.update() refuses a size change, because the nodes drawing a texture measured it once and would not hear about one. The exception is a texture whose size nothing has ever been able to measure, which has no size to be held to; a node showing one is in the same position and reads its dimensions from the upload.

format_supported(vk_format: int) bool

Return True if vk_format can be sampled from an optimal-tiled image.

The authoritative per-format gate consulted by the compressed-texture upload path: the coarse textureCompressionBC device feature must be present AND the specific BC format must report SAMPLED_IMAGE in its optimal-tiling features.

supports_compressed_format(vk_format: int) bool

Whether a block-compressed vk_format is usable on this device.

Gates on the coarse family feature for vk_format’s block family (BC / ASTC-LDR / ETC2, the single source of truth in self._capabilities) AND the per-format sampled-image feature. Consulted by TextureManager before dispatching a compressed upload.

compressed_caps() dict[str, bool]

Coarse block-compression family support for this device.

Returns the texture_compression_{bc,etc2,astc_ldr} subset of the probed capabilities. Consulted by TextureManager to pick a UASTC transcode target (BC7 -> ASTC-4x4 -> ETC2). Each coarse bit is necessary but not sufficient: a per-format SAMPLED check (format_supported) is the second gate. Public accessor so callers never reach into the private _capabilities.

upload_texture_blocks(blocks: list[bytes], width: int, height: int, fmt: int, block_size: int, *, filter: str = 'linear') int

Upload block-compressed mip data to GPU. Returns the bindless texture index.

Parallel to :meth:upload_texture_pixels for the uncompressed RGBA8 path: builds the image view with the COMPRESSED fmt and a levelCount spanning all supplied mips, then registers it into the bindless array and tracks it for teardown exactly like the uncompressed path.

load_mesh(file_path: str) simvx.graphics.types.MeshHandle

Load a glTF file’s first mesh primitive from disk and register it.

Returns: MeshHandle for use in rendering.

create_vertex_buffer(vertices: numpy.ndarray) tuple[Any, simvx.graphics.types._VkDeviceMemory]

Create a GPU vertex buffer and upload data. Returns (buffer, memory).

create_index_buffer(indices: numpy.ndarray) tuple[Any, simvx.graphics.types._VkDeviceMemory]

Create a GPU index buffer and upload data. Returns (buffer, memory).

create_ssbo(data: numpy.ndarray) tuple[Any, simvx.graphics.types._VkDeviceMemory]

Create an SSBO and upload data. Returns (buffer, memory).

update_ssbo(memory: simvx.graphics.types._VkDeviceMemory, data: numpy.ndarray) None

Update SSBO contents in-place via mapped memory.

create_descriptor_pool(max_sets: int = 4) simvx.graphics.types._VkDescriptorPool

Create a descriptor pool.

create_descriptor_set_layout(binding_count: int = 3) simvx.graphics.types._VkDescriptorSetLayout

Create a descriptor set layout with N SSBO bindings.

allocate_descriptor_set(pool: simvx.graphics.types._VkDescriptorPool, layout: simvx.graphics.types._VkDescriptorSetLayout) simvx.graphics.types._VkDescriptorSet

Allocate a descriptor set from pool.

write_descriptor_ssbo(descriptor_set: simvx.graphics.types._VkDescriptorSet, binding: int, buffer: Any, size: int) None

Bind an SSBO buffer to a descriptor set binding.

compile_and_load_shader(name: str) simvx.graphics.types._VkShaderModule

Compile a shader from SHADER_DIR and return its module.

update_vertex_buffer(memory: simvx.graphics.types._VkDeviceMemory, data: numpy.ndarray) None

Update vertex buffer contents in-place via mapped memory.

push_constants(cmd: simvx.graphics.types._VkCommandBuffer, pipeline_layout: simvx.graphics.types._VkPipelineLayout, data: bytes | bytearray) None

Push constant data (view + proj).

enable_picking(descriptor_layout: simvx.graphics.types._VkDescriptorSetLayout, descriptor_set: simvx.graphics.types._VkDescriptorSet) None

Initialize the GPU pick pass for mouse picking.

pick_entity(x: int, y: int, view_proj_data: bytes, vertex_buffer: Any, index_buffer: Any, index_count: int, instance_count: int) int

Read entity ID at screen position (x, y). Returns entity index or -1.

set_selected_objects(selected: list[tuple[simvx.graphics.types.MeshHandle, numpy.ndarray, int]]) None

Set the list of selected objects to highlight with outlines.

Args: selected: List of (mesh_handle, transform_4x4, material_id) tuples.

clear_selected_objects() None

Clear all selection outlines.

property outline_pass: simvx.graphics.renderer.outline_pass.OutlinePass | None

Access outline pass for configuration (colour, width, enabled).

create_text_texture(font: str | None = None, size: int = 32, width: int = 256, height: int = 64) Any

Create a texture with rendered text for use on 3D objects.

Returns a TextTexture with .text, .colour and .texture_index. Setting .text or .colour re-rasterises and writes the new pixels into the image the slot already holds, so texture_index is stable from the first render onward: bind it to a material once and later changes are seen through the same slot. Call .destroy() to release the slot and the image; without it they live until device teardown.

Web parity (simvx.web EngineStub.create_text_texture): the font argument is a no-op on web (text is rasterised from the atlas baked at export time). Everything else matches, texture_index stability and destroy() included.

set_key_callback(callback: collections.abc.Callable[[int, int, int], None]) None

Register callback(key, action, mods) for keyboard events.

set_mouse_button_callback(callback: collections.abc.Callable[[int, int, int], None]) None

Register callback(button, action, mods) for mouse button events.

set_cursor_pos_callback(callback: collections.abc.Callable[[float, float], None]) None

Register callback(x, y) for cursor position events.

set_scroll_callback(callback: collections.abc.Callable[[float, float], None]) None

Register callback(x_offset, y_offset) for scroll wheel events.

set_char_callback(callback: collections.abc.Callable[[int], None]) None

Register callback(codepoint) for character input events.

set_cursor_shape(shape: int) None

Set cursor shape. 0=arrow, 1=ibeam, 2=crosshair, 3=hand, 4=hresize, 5=vresize.

set_mouse_capture(mode: int) None

Apply a MouseCaptureMode int to the OS cursor via the active window backend.

property cursor_pos: tuple[float, float]

Current cursor position in screen coordinates.

run(callback: collections.abc.Callable[[], None] | None = None, setup: collections.abc.Callable[[], None] | None = None, render: collections.abc.Callable[[simvx.graphics.types._VkCommandBuffer, tuple[int, int]], None] | None = None, pre_render: collections.abc.Callable[[simvx.graphics.types._VkCommandBuffer], None] | None = None, cleanup: collections.abc.Callable[[], None] | None = None, frame_driver: collections.abc.Callable[[], None] | None = None, pre_shutdown: collections.abc.Callable[[], None] | None = None) None

Start the main loop.

Args: callback: Legacy per-frame callback (called before draw). setup: Called once after Vulkan init, before the loop. render: Custom render callback receiving (command_buffer, extent). If provided, replaces the built-in triangle rendering. pre_render: Called with command_buffer after vkBeginCommandBuffer but before the main render pass. Use for offscreen passes. cleanup: Called once when the loop exits, after the final vkDeviceWaitIdle but before shutdown() destroys the device. The right place to release caller-owned GPU resources (offscreen render targets, bindless slots) that must outlive the loop but die before the device. pre_shutdown: Called once when the loop exits, BEFORE the final vkDeviceWaitIdle. The pipelined driver stops + joins its render thread here so no GPU work is issued against the device while/after it is being torn down. None (default) is a no-op, unchanged. frame_driver: Pipelined-mode hook replacing the per-frame self._draw_frame() call on the MAIN thread. When given, the engine loop runs poll_events -> callback() ->              frame_driver() and never touches the GPU itself: the driver hands the frame’s render packet to a render thread that owns all GPU work. None (default) keeps the synchronous _draw_frame path, byte-identical to before.

begin(*, setup: collections.abc.Callable[[], None] | None = None, render: collections.abc.Callable[[simvx.graphics.types._VkCommandBuffer, tuple[int, int]], None] | None = None, pre_render: collections.abc.Callable[[simvx.graphics.types._VkCommandBuffer], None] | None = None) None

Create the window + Vulkan device and run setup.

The externally-driven counterpart to :meth:run: pair with :meth:step and :meth:end to advance frames under a caller-owned clock (the agent live-session / editor viewport). run is begin + a step loop + end.

step(*, callback: collections.abc.Callable[[], None] | None = None, frame_driver: collections.abc.Callable[[], None] | None = None) bool

Run one frame iteration (poll -> callback -> draw).

Returns False when the loop should stop (quit requested or window closed), True otherwise (including a paused/backgrounded frame, which is skipped).

end(*, pre_shutdown: collections.abc.Callable[[], None] | None = None, cleanup: collections.abc.Callable[[], None] | None = None) None

Tear down: quiesce the render thread, wait-idle, run cleanup, shutdown.

arm_capture() None

Arm a one-shot readback of the NEXT _draw_frame before it presents.

The captured pixels are retrieved with :meth:take_captured_frame. This is the acquire-before-transition capture path: the readback happens while the swapchain image is still acquired, so it never trips the “presentable image must be acquired” validation rule that the post-present :meth:capture_frame does. Used by both capture paths: the synchronous loop before it hands the frame to the driver, and the pipelined render thread before it records a packet’s frame.

One shot at exactly one frame, and the next _draw_frame spends it whether or not that frame reaches its present, so a frame abandoned to a device loss or a swapchain rebuild does not push the arm onto the frame after it.

take_captured_frame() numpy.ndarray | None

Return (and clear) the frame stashed by an armed capture, or None.

capture_frame() numpy.ndarray

Capture the last rendered framebuffer as an RGBA numpy array.

Returns (height, width, 4) uint8 array. Must be called after _draw_frame().

property window_size: tuple[int, int]

Current window (width, height). Writable to programmatically resize.

shutdown() None

Clean up all resources.