simvx.graphics.renderer.forward

Default renderer: Vulkan forward path; implements the RendererBackend ABC.

Module Contents

Classes

Renderer

Default Vulkan forward renderer: multi-draw indirect, per-viewport frustum culling.

Data

API

simvx.graphics.renderer.forward.__all__

[‘Renderer’]

simvx.graphics.renderer.forward.log

‘getLogger(…)’

class simvx.graphics.renderer.forward.Renderer(engine: Any, max_objects: int = 10000)

Bases: simvx.graphics.renderer._base.RendererBackend

Default Vulkan forward renderer: multi-draw indirect, per-viewport frustum culling.

Initialization

property draw2d_draw_count: int

The 2D draw count of the last main-framebuffer frame (telemetry).

The item pipeline submits through the bindless co-batched pass, so its last_frame_draw_count is the live count; 0 before the pass is first created (a scene that drew no 2D yet).

set_item_view(view: Any, camera: tuple) None

Bind a published 2D item view + camera affine for this frame’s submit.

The synchronous-path entry point: the frame loop calls this after publishing the item view, and render submits it through the bindless co-batched submitter. view=None means no 2D this frame. The pipelined path binds the same fields via install_packet from the packet’s item_view.

setup() None

Initialize GPU resources: called once after engine Vulkan init.

set_skybox(cubemap: simvx.graphics.engine.CubemapHandle) None

Set a cubemap as the skybox and run the IBL precompute.

Accepts a :class:~simvx.graphics.engine.CubemapHandle returned by

Meth:

Engine.load_cubemap. The renderer takes ownership of the underlying Vulkan resources and destroys them at shutdown. IBL precompute (irradiance + prefiltered specular + BRDF LUT) is run once per unique handle and cached, mirroring the web renderer’s IBLPass._byCubemapId lazy-precompute behaviour. Re-setting the same handle is a no-op past the cache check.

rebake_skybox_ibl() bool

Re-run the IBL convolution over the installed cubemap’s new texels.

For a caller that has rewritten the cube in place with

Meth:

Engine.update_cubemap_faces. Nothing about the installation changed – the image, the view, the sampler, the SkyboxPass and its pipeline are all still the right ones – so re-installing would tear all of that down and build it again to arrive back where it started. Only the ambient bake actually depends on the texels.

This is what makes an animated sky affordable. set_skybox is keyed on handle identity, so a rewritten cube under a retained handle would otherwise never re-bake and the ambient light would freeze at the first frame’s colours.

Returns False when there is nothing installed to re-bake.

property post_processing: simvx.graphics.renderer.post_process.PostProcessPass | None

Access post-processing pass for configuration.

property custom_post_processing: simvx.graphics.renderer.custom_post_process.CustomPostProcessPass | None

Access custom user post-process pass for configuration.

set_gizmo_data(data: simvx.graphics.renderer.gizmo_pass.GizmoRenderData | None) None

Set gizmo render data for the current frame (or None to hide).

property ssao: simvx.graphics.renderer.ssao_pass.SSAOPass | None

Access SSAO pass for configuration.

set_materials(materials: numpy.ndarray) None

Set the material array and upload it to this frame’s ring slot.

property shadow_caster_budget: int

Positional shadow casters of each kind the renderer will honour.

WorldEnvironment.shadow_caster_count when the scene sets it, else the engine default, bounded by the caster slots the shadow SSBO addresses AND by the rows the device will allocate an atlas for.

set_lights(lights: numpy.ndarray) None

Set the light array and upload it to this frame’s ring slot.

Prepends uint32 light_count to match GLSL LightBuffer layout: [uint32 count][Light[0]][Light[1]]…

Positional shadow casters are given their atlas row here, before the upload, because the row travels to the shader in Light.params.w.

capture_reflection_probes(cmd: Any, adapter: Any) bool

Capture any new / requested reflection probes. Returns True if any captured.

Driven from the app’s pre_render hook (before the main render pass) so a capture this frame is visible the same frame. Probe faces, the per-probe IBL convolution, and the cube-array copy all record into the SAME primary frame command buffer cmd: there is no separate submit/wait, so an always-mode probe no longer stalls the queue every frame. Probe face rendering reuses the offscreen scene path and clobbers per-frame submission lists (which render_to_target saves/restores).

capture_irradiance_volumes(cmd: Any, adapter: Any) bool

Bake budgeted irradiance-volume probes into the b18 SH SSBO.

Driven from pre_render right after :meth:capture_reflection_probes, into the same primary cmd: the SH-reduce compute writes this frame’s probes and a buffer barrier makes them visible to the main pass’s uber reads, so a baked probe lights the scene the same frame. Flips _irradiance_volume_enabled (the uber SH gate) on when a volume is present; when none exist the flag stays off and the frame is byte-identical to a scene with no volume.

init(device: Any, swapchain: Any) None

Initialize (called by ABC contract: use setup() instead).

begin_frame() Any

Begin frame: grow the transform arena if scheduled, then clear submission lists.

submit_instance(mesh_handle: simvx.graphics.types.MeshHandle, transform: numpy.ndarray, material_id: int = 0, viewport_id: int = 0, render_layers: int = 0) None

Submit a mesh instance for rendering this frame.

render_layers is the instance’s render-layer bitmask, uploaded into the Transform SSBO so the decal uber can enforce Decal3D.cull_mask. 0 means “all layers” (every decal received).

submit_multimesh(mesh_handle: simvx.graphics.types.MeshHandle, transforms: numpy.ndarray, material_id: int = 0, material_ids: numpy.ndarray | None = None, viewport_id: int = 0, count: int = 0, cache_key: int = 0, version: int = 0) None

Bulk-submit many instances of the same mesh: avoids per-instance Python loops.

Args: mesh_handle: Shared mesh for all instances. transforms: (N, 4, 4) float32 array of model matrices. material_id: Material index for all instances (ignored if material_ids given). material_ids: Optional (N,) uint32 array of per-instance material indices. viewport_id: Viewport index. count: Ignored here (transforms already sliced); accepted for ABI parity with web. cache_key / version: stable per-node id + content generation; let _upload_multimesh_blocks skip re-uploading an unchanged static block.

submit_shader_instance(mesh_handle: simvx.graphics.types.MeshHandle, transform: numpy.ndarray, material_id: int, shader_material: Any, texture_slots: dict[str, int] | None = None) None

Submit a MeshInstance3D that carries a ShaderMaterial.

The per-material pipeline is lazy-compiled + cached on first use via ShaderMaterialManager. During the forward draw, the renderer splits these submissions into their own bucket, binds the custom pipeline, updates the uniform buffer, and draws one mesh at a time, per- material pipeline switching is the cost of the custom-shader path.

texture_slots maps each texture name the material bound to the backend handle the scene adapter resolved it to, so the manager writes the material’s descriptor group without loading anything itself.

submit_particles(particle_data: numpy.ndarray) None

Submit particle data for rendering this frame.

msdf_atlas_slot() int

Ensure the MSDF atlas is uploaded + bound in the bindless array; return its slot.

Used by the Billboard2DPass collector (scene_adapter) to bake the atlas slot into Text3D glyph rows. Must be called OUTSIDE any render pass (it does staging transfers + vkUpdateDescriptorSets via register_texture / update_texture); submit_scene is such a point. Returns -1 when there is no text pass or no atlas yet.

The slot is reused across frames; if the atlas view is rebuilt (font / glyph-set change), the same slot is rewritten so baked references stay valid (assign-once, like the bindless-pass atlas slot).

submit_billboards(billboards: numpy.ndarray) None

Submit depth-tested billboard instances (Sprite3D / Text3D) for this frame.

billboards is a BILLBOARD_DTYPE array (one row per Sprite3D quad or Text3D glyph). All submissions are concatenated and drawn in one call by

Class:

Billboard2DPass inside render_scene_content.

draw_sprite_3d(*, texture_id: int, position: Any, width: float, height: float, uv: tuple[float, float, float, float], colour: Any = (1.0, 1.0, 1.0, 1.0), billboard: bool = True, centered: bool = True, offset: tuple[float, float] = (0.0, 0.0), alpha_cut: str = 'disabled', render_priority: int = 0) None

Emit one Sprite3D billboard quad (called by Sprite3D.on_draw).

Builds a single BILLBOARD_DTYPE row: the quad is anchored at position and sized width x height in world units on the camera-facing plane. centered keeps the quad on the anchor; otherwise its top-left corner sits on the anchor. The camera basis is applied GPU-side by Billboard2DPass.

draw_text_3d(*, text: str, position: Any, font_scale: float = 1.0, pixel_size: float = 0.01, colour: Any = (1.0, 1.0, 1.0, 1.0), alignment: str = 'centre', offset: tuple[float, float] = (0.0, 0.0)) None

Emit a Text3D MSDF glyph billboard run (called by Text3D.on_draw).

Reuses the unified glyph layout (:func:layout_glyph_run) so kerning / size match every other text node, then maps the pixel-space glyph quads into world units (pixel_size) on the camera-facing plane, centred about the anchor, and emits one MSDF billboard row per glyph (is_msdf set so Billboard2DPass decodes glyph AA). The MSDF atlas must already be bound bindless (the collector ensured the slot outside the render pass).

submit_gpu_particles(emitter_config: dict, *, emitter_id: int = 0) None

Submit a GPU particle emitter config for compute-shader simulation this frame.

emitter_id is a stable per-node identifier (id(node) & 0xFFFFFFFF from :class:SceneAdapter). :class:ParticleCompute keys its persistent SSBOs by this id so multi-emitter scenes render correctly: matches the web GPUParticlePass ownership model.

submit_light2d(**kwargs) None

Submit a 2D light for this frame (forwarded to Light2DPass).

submit_occluder2d(polygon_vertices: list[tuple[float, float]]) None

Submit a 2D occluder polygon for shadow casting this frame.

submit_skinned_instance(mesh_handle: simvx.graphics.types.MeshHandle, transform: numpy.ndarray, material_id: int, joint_matrices: numpy.ndarray) None

Submit a skinned mesh instance with joint matrices for this frame.

install_packet(packet: Any) None

Bind a :class:RenderPacket’s owned snapshot onto the per-frame state.

Called on the RENDER THREAD (under _frame_state_lock) immediately before recording a pipelined GPU frame. It rebinds the renderer’s per-frame submission attributes to the packet’s OWNED copies and rebuilds viewport_manager from the packet’s viewport snapshots, so the existing _upload_transforms / pre_render / render body records from the snapshot instead of from live state the main thread is concurrently rebuilding. This is the same temporary-rebind pattern render_to_target uses for offscreen SRUs (scene_adapter ~1046), here for the whole frame.

The renderer’s own set_materials / set_lights still own the GPU material/light SSBO uploads; this only restores the CPU-side arrays the recording reads, so passes that re-read _materials / _lights see the snapshot. (Those SSBOs are written by pre_render/_upload on this same render thread, so a single GPU buffer remains correct.)

tilemap_layers() list

Tilemap submissions the render pass should draw this frame.

Returns the packet’s owned snapshot when a packet is installed (pipelined render thread), else the live _tilemap_pass._submissions list (synchronous path, byte-identical). [] when there is no tilemap pass.

light2d_submissions() tuple[list, list]

(lights, occluders) the 2D-light pass should render this frame.

Packet-owned snapshots in pipelined mode, else the live _light2d_pass lists (synchronous path, byte-identical). ([], []) when there is no 2D-light pass.

reserve_main_slice() int

Reserve the main scene’s transform-SSBO slice at the front of the frame.

Called before any offscreen scene-render unit (SubViewport / probe face) reserves, so the main scene is the FIRST SRU and keeps base 0: its absolute first_instance indices equal the local indices (unchanged single-scene behaviour). Offscreen SRUs then reserve slices after it. Idempotent within a frame (the arena cursor only advances on the first call). Returns the base.

property tiled_lighting_enabled: bool

Whether a many-light frame may cull its lights into screen tiles.

Off by default. When on, a frame carrying at least

Data:

~simvx.graphics.renderer.light_cull_pass.TILED_LIGHTING_MIN_LIGHTS lights runs a compute pass that assigns each light to the 16x16 pixel tiles it can reach, and the fragment shader loops over its own tile’s list instead of every light in the scene. Below the threshold, and on any frame that also records an offscreen scene-render unit, the frame takes the brute-force loop unchanged.

sync_render_state() None

Propagate WorldEnvironment state and run feature activations.

Split out of :meth:pre_render so the frame loop can run it BEFORE it records offscreen RenderView / SubViewport draws. The G-buffer / SSR / SSGI activation inside sync_world_environment may vkDeviceWaitIdle and rebuild the HDR-pass pipelines; doing that after an offscreen target has already recorded pipeline binds into the open command buffer would invalidate that buffer (a hard crash when SSR is combined with a live PlanarReflection3D). This records nothing into a command buffer, so hoisting it is safe.

Guarded to run at most once per frame (reset in :meth:begin_frame): the earliest caller performs the sync, and the later :meth:pre_render call is a no-op, so there is exactly one activation transition per frame and it always precedes any offscreen pipeline binding.

pre_render(cmd: Any) None

Record offscreen passes (shadow maps, HDR) before main render pass begins.

render(cmd: Any) None

Record draw commands for all viewports.

apply_gbuffer_state(active: bool) None

Activate/deactivate the thin G-buffer.

Flipping it changes the HDR pass’s colour-attachment count, so the HDR target and every pipeline drawing into it must be rebuilt. This is rare (a consumer toggling on/off) and idempotent: a no-op when the state already matches. Requires the post-process HDR pipeline (the G-buffer rides its second attachment); ignored until it exists.

Mirrors the env_sync device-wait-idle + rebuild idiom: it only runs on a transition, never per frame.

apply_ssr_state(active: bool, *, intensity: float = 1.0, max_distance: float = 40.0, roughness_cutoff: float = 0.6) None

Activate/deactivate screen-space reflections.

SSR requires the post-process HDR path AND the thin G-buffer (env-sync turns the G-buffer on for SSR before calling this). When it cannot run, or is disabled, b16 falls back to the placeholder and the uber’s indirect_specular_enabled gate goes 0: byte-identical to feature-off. Idempotent: the descriptor rebind + flag flip only happen on a transition.

apply_ssgi_state(active: bool, *, intensity: float = 1.0, max_distance: float = 8.0) None

Activate/deactivate screen-space global illumination.

The diffuse mirror of :meth:apply_ssr_state. SSGI requires the post-process HDR path AND the thin G-buffer (env-sync turns the G-buffer on for SSGI before calling this). When it cannot run, or is disabled, b17 falls back to the placeholder and the uber’s indirect_diffuse_enabled gate goes 0: byte-identical to feature-off. Idempotent: the descriptor rebind + flag flip only happen on a transition.

apply_probe_quality() None

Apply the WorldEnvironment reflection-probe quality dials.

_probe_blend_count / _probe_face_size are written by the env-sync spec each frame; None keeps the built-in defaults (top-2 blend, 128 px faces), i.e. the exact pre-knob paths. Both downstream setters are transition-gated no-ops when the effective value is unchanged, so this per-frame call is free in steady state. A blend count of 0 leaves the shader permutation alone: the capture gate in

Meth:

capture_reflection_probes keeps the probe set empty, which makes the blend loop a branch-free no-op.

apply_shadow_quality() None

Apply the WorldEnvironment positional shadow caster budget.

_shadow_caster_count is written by the env-sync spec each frame; None keeps the built-in default of one caster of each kind, which holds the atlases at one row. It is not a return to the pre-budget image: with more than one positional light the frame differs, because each light is now darkened by its own map alone instead of by the single caster’s. The atlas resize is a transition-gated no-op when the effective value is unchanged, so this per-frame call is free in steady state. A budget of 0 leaves the atlases alone: no light is handed a caster slot, which is what switches shadows off.

No shader permutation depends on the count – the atlas row divide reads it out of the shadow SSBO – but a real change still drains the device to reallocate the atlases, so it must land before the frame records anything: this is called from sync_world_environment, which the frame loop runs through :meth:sync_render_state ahead of every offscreen and main-pass recording, for the same reason the G-buffer activation is.

property render_scale: float

Resolution scale of the main-view HDR chain.

internal_extent = ceil(output_extent * render_scale) sizes the whole HDR chain (colour + depth, SSAO, bloom inputs, volumetric fog, velocity); the tonemap fullscreen draw bilinearly upscales to the swapchain. With TAA enabled the resolve upgrades that upscale to TAAU: its output + history live at the OUTPUT extent and accumulate sub-pixel detail from the jittered internal-res frames. 1.0 (default) is byte-identical to the unscaled path. Driven by WorldEnvironment.render_scale via the env-sync spec; an actual change drains the device and rebuilds the chain (the same rare transition as a window resize), so per-frame re-assignment of the same value is free.

internal_extent(width: int | None = None, height: int | None = None) tuple[int, int]

HDR-chain render extent: ceil(output * render_scale), min 1 px.

Defaults to the engine’s current output extent. Identity when render_scale is 1.0 or post-processing is off (no HDR chain means no upscale point, so the scale is inert and shaders must not be told otherwise).

resize(width: int, height: int) None

Handle framebuffer resize: recreate post-process targets + 3D pipelines.

width / height are the OUTPUT (swapchain) extent; the HDR chain and every pass that renders in HDR space is (re)created at the internal extent. Identical to the output extent at render_scale 1.0.

cleanup() None

Release all GPU resources.

destroy() None

ABC destroy: delegates to cleanup.

register_mesh(streams: simvx.graphics.types.VertexStreams, indices: numpy.ndarray) simvx.graphics.types.MeshHandle

Register a mesh’s vertex streams on GPU via engine’s mesh registry.

upload_texture_pixels(pixels: numpy.ndarray, width: int, height: int) int

Upload RGBA pixel data to GPU, return bindless texture index.

capture_frame() numpy.ndarray

Capture the last rendered frame as (H, W, 4) uint8 RGBA numpy array.

__slots__

()