simvx.graphics.renderer.buffer_manager

BufferManager: owns the forward renderer’s SSBOs and descriptor sets.

Extracted from Renderer so transform/material/light/shadow/joint buffers and the Forward+ tile bindings live in one place. The descriptor set layout and cubemap placeholder are owned here too so the renderer can swap the IBL cubemap in via write_cubemap_descriptor.

Bindings 5 and 6 start on 16-byte placeholders and are repointed at the light cull pass’s per-frame tile buffers by write_tile_buffers the first time a frame arms tiled lighting. The placeholders are never freed, so no binding index moves and a scene that never asks for the feature keeps the layout it always had.

Module Contents

Classes

BufferManager

Owns the renderer’s SSBOs and descriptor sets.

Data

API

simvx.graphics.renderer.buffer_manager.__all__

[‘BufferManager’, ‘SHADOW_DATA_SIZE’]

simvx.graphics.renderer.buffer_manager.log

‘getLogger(…)’

simvx.graphics.renderer.buffer_manager.MAX_PROBES

8

simvx.graphics.renderer.buffer_manager.PROBE_BUFFER_SIZE

None

simvx.graphics.renderer.buffer_manager.IRRADIANCE_VOLUME_HEADER_SIZE

64

simvx.graphics.renderer.buffer_manager.IRRADIANCE_VOLUME_MAX_PROBES

256

simvx.graphics.renderer.buffer_manager.IRRADIANCE_VOLUME_BUFFER_SIZE

None

class simvx.graphics.renderer.buffer_manager.BufferManager(engine: Any, max_objects: int, max_materials: int = 1024, max_lights: int = 256, max_joints: int = 256)

Owns the renderer’s SSBOs and descriptor sets.

Main descriptor set (ssbo_set) exposes eighteen bindings: 0: transforms, 1: materials, 2: lights, 3: shadow, 4: IBL cubemap sampler, 5: tile light indices, 6: tile info, 7/8/9: IBL irradiance cube / prefilter cube / BRDF 2D LUT, 10/11: reflection-probe irradiance / prefilter cube arrays, 12: reflection-probe box SSBO, 13: FrameGlobals UBO, 14: scene colour copy, 15: scene depth copy (written by the renderer’s :class:SceneCopyTargets), 16/17: indirect specular / diffuse hook textures (1x1 black until an SSR / SSGI producer writes them).

Joint descriptor set (joint_set) is set 2 binding 0 for skinned meshes.

Initialization

property transform_buf: Any
property transform_mem: Any
property aabb_buf: Any
property aabb_mem: Any
property material_buf: Any
property material_mem: Any
property light_buf: Any
property light_mem: Any
property light_bufs: list[Any]

Every ring slot’s light SSBO, in slot order.

For a pass that owns its own per-frame descriptor sets and must bind slot i’s buffer into slot i’s set. Binding one slot’s buffer into all of them is the failure this exists to make unwritable: that pass would then read a different frame’s lights than the forward pass shades with, on every frame the ring is not on that slot.

property shadow_buf: Any
property shadow_mem: Any
property frame_globals_buf: Any
property frame_globals_mem: Any
property ssbo_set: Any

The current frame’s main SSBO descriptor set (what every pass binds).

property joint_buf: Any
property joint_mem: Any
property joint_set: Any

The current frame’s joint descriptor set (bound for skinned meshes).

setup() None

Allocate all SSBOs and descriptor sets.

property transform_capacity: int

Total transform-SSBO slots available this frame (== max_objects).

begin_frame_arena() None

Reset the per-frame bump cursor. Call once at the start of every frame.

property scene_render_units: int

Scene-render units that have uploaded transforms so far this frame.

One per unit, main scene included, so a frame that recorded no SubViewport, RenderView or probe face answers 1 once the main scene has uploaded. A pass whose per-frame state is one value for the whole command buffer (the Forward+ tile grid width) reads this to tell a single-unit frame from a multi-unit one.

property last_frame_scene_render_units: int

What :attr:scene_render_units reached by the end of the previous frame.

Most offscreen units upload before the main scene records, so the running count already sees them. The editor’s play-mode game viewport does not: it hangs off Engine.pre_render_callback and records after the renderer’s own pre_render has returned. A gate that must know whether the frame is single-unit reads this as well, and a viewport that has been on screen for one frame is then visible to it.

reserve_slots(count: int) int

Reserve a contiguous range of count transform slots; return its base.

Bump-allocates from the shared SSBO arena. On overflow (the running cursor would pass capacity) the request is clamped to what fits, a WARNING is logged once, and a grow to next_pow2(needed) is scheduled for the next frame boundary. The high-water mark is updated either way so a proactive grow can fire before an actual overflow recurs.

Returns the base slot. The caller records draws with first_instance = base + local_index and writes its transforms into the SSBO at byte offset base * TRANSFORM_DTYPE.itemsize.

request_joint_capacity(joint_count: int) int

Reserve room for joint_count bone matrices in the joint SSBO.

Returns the number that fit in the CURRENT buffer (so the caller clamps its upload and never writes past the allocation). When the request exceeds capacity a grow is scheduled for the next frame boundary (mirrors the transform arena), so the dropped skeletons appear one frame later instead of corrupting device memory. Symmetric with the web _ensureBoneBuf.

maybe_grow() bool

Grow the transform/AABB, joint and material SSBOs if scheduled. Frame-boundary only.

Reallocates when a grow is pending (an overflow clamped this frame) OR the high-water mark has crossed _GROW_THRESHOLD of capacity (proactive). Must run OUTSIDE command recording (begin_frame or post-submit) so the reallocated buffer + descriptor rewrite never race the GPU.

Returns True when a grow changed the OBJECT capacity, which is what the renderer’s slot-aligned resources (indirect batches, visibility buffers) follow. The material SSBO has no slot-aligned dependents, so growing it alone reports no capacity change.

write_probe_descriptors(irradiance_array_view: Any, prefilter_array_view: Any, sampler: Any) None

Bind the ReflectionProbePass’s cubemap arrays to the forward set (bindings 10/11).

write_probe_buffer(data: numpy.ndarray) None

Upload the probe box SSBO bytes (count header + Probe array, binding 12).

write_decals(data: numpy.ndarray) None

Upload the decal SSBO bytes (count header + Decal array + tile masks, b19).

data is the block from decal_pack.build_decal_buffer. The base (header + record) region is a fixed :data:DECAL_BUFFER_SIZE; when the screen-tile cull is active the per-tile bitmask array is appended, so the block can exceed the initial allocation. It is grown (and its b19 descriptor repointed) on demand; the growth only ever adds tile-mask capacity, so it never perturbs the record region. An all-zero block (no decals) leaves decal_count at 0, a byte-identical no-op.

write_irradiance_volume_header(header: numpy.ndarray) None

Upload ONLY the volume SSBO header (64 B: grid + bounds + params, binding 18).

The per-probe SH region that follows is written by the IrradianceVolumePass SH-reduce compute (GPU), so the header upload must never touch it.

write_ibl_descriptors(irradiance_view: Any, prefilter_view: Any, brdf_view: Any, sampler: Any) None

Bind an IBLPass’s precomputed maps to the forward set (bindings 7/8/9).

upload_transforms(instances: list, *, upload_aabbs: bool = False, base: int = 0, pack_mesh_ids: bool = False) numpy.ndarray | None

Upload instance transforms + normal matrices + material ids into the SSBO slice.

Writes into the shared transform SSBO starting at slot base (the slice reserved for this scene-render unit; base == 0 for the main scene). Returns the (N, 4, 4) column-major (GLSL-ready) model matrices so the caller can feed the exact same data to the TAA velocity pass (prev-frame transform plumbing). Returns None when there are no instances.

When upload_aabbs is True (GPU occlusion culling enabled) the per-slot LOCAL AABB SSBO is populated alongside. Off by default so the occlusion-off path performs no extra host upload (byte-identical default behaviour).

When pack_mesh_ids is True, :attr:instance_block is left holding what was uploaded – the same model matrices, plus the mesh and material index per slot – for consumers that need to know whether this frame’s block differs from the last one’s; it is None otherwise. Off by default, like upload_aabbs, because only the positional shadow row cache reads it and gathering the mesh ids is a second pass over the instance list: packing them unconditionally cost the pack loop about a tenth of itself on frames with no positional shadow caster to spend it on.

Exactly one call per scene-render unit that draws, whether or not it has instances, which is what :attr:scene_render_units counts. MultiMesh blocks go through :meth:upload_transform_block instead, so a scene full of them is still one unit.

upload_transform_block(transforms: numpy.ndarray, base: int, *, material_index: int = 0, material_ids: numpy.ndarray | None = None) numpy.ndarray | None

Vectorized upload of a contiguous instance block (e.g. a MultiMesh).

Unlike :meth:upload_transforms, which iterates a Python list of per- instance tuples, this packs the whole (N, 4, 4) block with no Python loop and writes it at slots [base, base+N). Returns the world-space instance centres (N, 3) (translation columns) so the caller can frustum-cull the block without re-reading the SSBO. Returns None when the block does not fit.

set_materials(materials: numpy.ndarray) numpy.ndarray

Upload the material array, growing the SSBO when a frame outgrows it.

Returns the (possibly clamped) array stored. A frame needing more rows than the buffer holds renders with the rows that fit and schedules a grow for the next frame boundary (:meth:maybe_grow), exactly as the transform arena and the joint buffer do: reallocating here would pull the buffer out from under the frames still in flight. The high-water mark is tracked either way, so the proactive grow usually fires before an overflow.

The WARNING is once per overflow episode, not once per frame: an episode ends when the buffer grows or when a frame fits again. A manager driven without frame boundaries (nothing calls :meth:maybe_grow) therefore still reports every episode rather than falling silent after the first.

The upload targets the recording frame’s ring slot and is not gated on the content having changed. Gating it cost a hash(...tobytes()), which serialises the whole array to decide whether to copy it, and the array is a few tens of kilobytes.

set_lights(lights: numpy.ndarray) None

Upload the light array behind its count header (GLSL LightBuffer layout).

Clamped to :attr:max_lights, with the header carrying the clamped count so no shader reads past what was written. Nothing upstream limits how many lights a scene submits, and the block is

Data:

_LIGHT_HEADER_BYTES longer than the array it carries, so an unclamped upload of a scene at capacity runs off the end of the allocation.

Writes the recording frame’s ring slot, ungated. A scene may call this several times in one frame (the caster budget re-slots, an offscreen unit submits its own set), and every call lands in the same slot, which is the frame’s own; no call can reach the slot a submitted frame is reading.

prime_frame_slots(materials: numpy.ndarray) None

Write materials, and an empty light block, into EVERY ring slot.

Setup only, and it is not the same job as the per-frame upload. A slot no frame has reached yet holds whatever the allocator left in it, and the light block’s first word is the count the fragment shader loops on, so an unseeded slot is not merely stale: it is a loop bound made of uninitialised memory.

The light block is seeded EMPTY rather than with a placeholder row. A count of zero is what the shader keys its “this scene has no lights” fallback on, and a zeroed row is not a harmless stand-in for it: the row reads as a directional light whose direction is the zero vector, which normalises to NaN.

write_shadow_data(shadow_data: numpy.ndarray) None

Upload raw shadow SSBO bytes (used by shadow passes + IBL-only fallback).

write_shadow_uint(field: str, value: int) None

Patch one uint of the current frame’s shadow block, leaving the rest.

Three passes write that block and each of them rewrites it whole, so a word the renderer owns per frame rather than any one of them (the Forward+ tile grid width) is published here, after they have run, instead of being threaded through all three. Raises on an unknown or wrong-typed field name, as the block’s other writers do.

write_tile_buffers(index_bufs: list[Any], index_size: int, tile_bufs: list[Any], tile_size: int) None

Point bindings 5 and 6 at the light-cull pass’s ringed tile buffers.

Set i takes ring copy i: the fragment stage of the frame recording into set i must read the lists the compute of that same frame wrote, not the ones the other frame in flight is writing. This replaces the 16-byte placeholders bound at setup, which stay allocated for the device’s life so no binding index ever moves.

Neither binding carries UPDATE_AFTER_BIND (see gpu/descriptors.py: only the combined image samplers do), so the caller must have waited the device out first, exactly as

Meth:

write_decals does when it grows binding 19. Both callers do: the pass is built once, and a resize runs behind the wait recreate_swapchain already takes.

write_cubemap_descriptor(view: Any, sampler: Any) None

Bind a cubemap view+sampler to the IBL slot (binding 4).

update_frame_globals(now: float, env: simvx.graphics.frame_globals.FrameGlobalsEnv, internal_extent: tuple[int, int], output_extent: tuple[int, int], render_scale: float = 1.0) None

Repack + upload the per-frame FrameGlobals UBO (binding 13).

delta is derived from the scene clock (now minus the previous call’s now) and frame_index from an internal counter, so the block needs no renderer-wide frame clock. internal_extent is the HDR-chain render extent, output_extent the swapchain extent; they are equal (and render_scale 1.0) on the unscaled path, packing the exact pre-scale block.

cleanup() None

Destroy all buffers, descriptor pools/layouts, and placeholder cubemap.