"""BufferManager — owns the forward renderer's SSBOs and descriptor sets.
Extracted from Renderer so transform/material/light/shadow/joint
buffers and the Forward+ tile-culling placeholders 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``.
"""
import logging
from typing import Any
import numpy as np
import vulkan as vk
from .._types import LIGHT_DTYPE, MATERIAL_DTYPE, TRANSFORM_DTYPE
from ..gpu.descriptors import (
allocate_descriptor_set,
create_descriptor_pool,
create_ssbo_layout,
write_image_descriptor,
write_ssbo_descriptor,
)
from ..gpu.memory import create_buffer, upload_numpy
__all__ = ["BufferManager", "SHADOW_DATA_SIZE"]
log = logging.getLogger(__name__)
# Shadow SSBO total size — must match the ShadowBuffer struct in cube_textured.frag.
# Layout: cascade_vps[3](192) + cascade_splits(16) + flags/indices(32) +
# point_light_pos_range(16) + spot_vp(64) + spot_light_pos_range(16) + ambient_colour(16) = 352
SHADOW_DATA_SIZE = 352
# Default ambient colour (cool grey fill) written at offset 336 when no WorldEnvironment overrides it.
_DEFAULT_AMBIENT = np.array([0.15, 0.15, 0.2, 1.0], dtype=np.float32)
_HOST_FLAGS = vk.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | vk.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
_SSBO_USAGE = vk.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT
[docs]
class BufferManager:
"""Owns the renderer's SSBOs and descriptor sets.
Main descriptor set (``ssbo_set``) exposes seven bindings:
0: transforms, 1: materials, 2: lights, 3: shadow,
4: IBL cubemap sampler, 5: tile light indices, 6: tile info.
Joint descriptor set (``joint_set``) is set 2 binding 0 for skinned meshes.
"""
def __init__(self, engine: Any, max_objects: int, max_materials: int = 1024,
max_lights: int = 256, max_joints: int = 256) -> None:
self._engine = engine
self.max_objects = max_objects
self.max_materials = max_materials
self.max_lights = max_lights
self.max_joints = max_joints
# SSBO resources
self.transform_buf: Any = None
self.transform_mem: Any = None
self.material_buf: Any = None
self.material_mem: Any = None
self.light_buf: Any = None
self.light_mem: Any = None
self.shadow_buf: Any = None
self.shadow_mem: Any = None
self.tile_light_idx_buf: Any = None
self.tile_light_idx_mem: Any = None
self.tile_info_buf: Any = None
self.tile_info_mem: Any = None
self.joint_buf: Any = None
self.joint_mem: Any = None
# Descriptors
self.ssbo_layout: Any = None
self.ssbo_pool: Any = None
self.ssbo_set: Any = None
self.joint_layout: Any = None
self.joint_pool: Any = None
self.joint_set: Any = None
# IBL cubemap placeholder (owned for lifetime of manager)
self.placeholder_cubemap_view: Any = None
self.placeholder_cubemap_sampler: Any = None
self.placeholder_cubemap_img: Any = None
self.placeholder_cubemap_mem: Any = None
# Dirty-tracking: skip redundant GPU uploads when data hasn't changed
self._materials_hash: int = 0
self._lights_hash: int = 0
# ------------------------------------------------------------------ setup
[docs]
def setup(self) -> None:
"""Allocate all SSBOs and descriptor sets."""
e = self._engine
device = e.ctx.device
phys = e.ctx.physical_device
transform_size = self.max_objects * TRANSFORM_DTYPE.itemsize
material_size = self.max_materials * MATERIAL_DTYPE.itemsize
light_size = self.max_lights * LIGHT_DTYPE.itemsize
joint_buf_size = self.max_joints * 64 # mat4 = 64 bytes
self.transform_buf, self.transform_mem = create_buffer(device, phys, transform_size, _SSBO_USAGE, _HOST_FLAGS)
self.material_buf, self.material_mem = create_buffer(device, phys, material_size, _SSBO_USAGE, _HOST_FLAGS)
self.light_buf, self.light_mem = create_buffer(device, phys, light_size, _SSBO_USAGE, _HOST_FLAGS)
self.shadow_buf, self.shadow_mem = create_buffer(device, phys, SHADOW_DATA_SIZE, _SSBO_USAGE, _HOST_FLAGS)
self.tile_light_idx_buf, self.tile_light_idx_mem = create_buffer(device, phys, 16, _SSBO_USAGE, _HOST_FLAGS)
self.tile_info_buf, self.tile_info_mem = create_buffer(device, phys, 16, _SSBO_USAGE, _HOST_FLAGS)
self.joint_buf, self.joint_mem = create_buffer(device, phys, joint_buf_size, _SSBO_USAGE, _HOST_FLAGS)
# Main SSBO set: 4 SSBOs + 1 cubemap sampler + 2 trailing SSBOs
self.ssbo_layout = create_ssbo_layout(device, binding_count=4, extra_samplers=1, trailing_ssbos=2)
self.ssbo_pool = create_descriptor_pool(device, max_sets=1, extra_samplers=1, ssbo_count=6)
self.ssbo_set = allocate_descriptor_set(device, self.ssbo_pool, self.ssbo_layout)
write_ssbo_descriptor(device, self.ssbo_set, 0, self.transform_buf, transform_size)
write_ssbo_descriptor(device, self.ssbo_set, 1, self.material_buf, material_size)
write_ssbo_descriptor(device, self.ssbo_set, 2, self.light_buf, light_size)
write_ssbo_descriptor(device, self.ssbo_set, 3, self.shadow_buf, SHADOW_DATA_SIZE)
write_ssbo_descriptor(device, self.ssbo_set, 5, self.tile_light_idx_buf, 16)
write_ssbo_descriptor(device, self.ssbo_set, 6, self.tile_info_buf, 16)
# Joint SSBO set (set 2, binding 0)
self.joint_layout = create_ssbo_layout(device, binding_count=1)
self.joint_pool = create_descriptor_pool(device, max_sets=2)
self.joint_set = allocate_descriptor_set(device, self.joint_pool, self.joint_layout)
write_ssbo_descriptor(device, self.joint_set, 0, self.joint_buf, joint_buf_size)
# Shadow SSBO defaults: no-shadow sentinels + ambient colour
init_shadow = np.zeros(SHADOW_DATA_SIZE, dtype=np.uint8)
sentinel = np.array([0xFF, 0xFF, 0xFF, 0xFF], dtype=np.uint8)
init_shadow[208:212] = sentinel
init_shadow[220:224] = sentinel
init_shadow[224:228] = sentinel
init_shadow[336:352] = _DEFAULT_AMBIENT.view(np.uint8)
upload_numpy(device, self.shadow_mem, init_shadow)
# IBL cubemap placeholder (replaced by Renderer.set_skybox)
from ..assets.cubemap_loader import load_cubemap
(
self.placeholder_cubemap_view, self.placeholder_cubemap_sampler,
self.placeholder_cubemap_img, self.placeholder_cubemap_mem,
) = load_cubemap(device, phys, e.ctx.graphics_queue, e.ctx.command_pool, colour=(0.0, 0.0, 0.0))
write_image_descriptor(
device, self.ssbo_set, 4, self.placeholder_cubemap_view, self.placeholder_cubemap_sampler
)
# ---------------------------------------------------------------- uploads
[docs]
def set_materials(self, materials: np.ndarray) -> np.ndarray:
"""Upload material array. Returns the (possibly clamped) array stored."""
if len(materials) > self.max_materials:
log.warning("Material count (%d) exceeds max (%d), clamping", len(materials), self.max_materials)
materials = materials[: self.max_materials]
if self.material_mem:
h = hash(materials.tobytes())
if h != self._materials_hash:
self._materials_hash = h
upload_numpy(self._engine.ctx.device, self.material_mem, materials)
return materials
[docs]
def set_lights(self, lights: np.ndarray) -> None:
"""Upload light array prefixed with the uint32 count (GLSL LightBuffer layout)."""
if not self.light_mem:
return
h = hash(lights.tobytes())
if h == self._lights_hash:
return
self._lights_hash = h
count = np.array([len(lights)], dtype=np.uint32)
padding = np.zeros(3, dtype=np.uint32)
header = np.concatenate([count, padding])
buf = np.concatenate([header.view(np.uint8), lights.view(np.uint8)])
upload_numpy(self._engine.ctx.device, self.light_mem, buf)
[docs]
def set_hdr_flag(self, enabled: bool) -> None:
"""Toggle ``hdr_output`` (byte offset 216) in the shadow SSBO."""
flag = np.array([1 if enabled else 0], dtype=np.uint32)
shadow_data = np.zeros(SHADOW_DATA_SIZE, dtype=np.uint8)
ffi = vk.ffi
device = self._engine.ctx.device
src = vk.vkMapMemory(device, self.shadow_mem, 0, SHADOW_DATA_SIZE, 0)
ffi.memmove(ffi.cast("void*", shadow_data.ctypes.data), src, SHADOW_DATA_SIZE)
vk.vkUnmapMemory(device, self.shadow_mem)
shadow_data[216:220] = flag.view(np.uint8)
upload_numpy(device, self.shadow_mem, shadow_data)
[docs]
def write_shadow_data(self, shadow_data: np.ndarray) -> None:
"""Upload raw shadow SSBO bytes (used by shadow passes + IBL-only fallback)."""
upload_numpy(self._engine.ctx.device, self.shadow_mem, shadow_data)
[docs]
def write_cubemap_descriptor(self, view: Any, sampler: Any) -> None:
"""Bind a cubemap view+sampler to the IBL slot (binding 4)."""
write_image_descriptor(self._engine.ctx.device, self.ssbo_set, 4, view, sampler)
# ---------------------------------------------------------------- cleanup
[docs]
def cleanup(self) -> None:
"""Destroy all buffers, descriptor pools/layouts, and placeholder cubemap."""
device = self._engine.ctx.device
for buf, mem in (
(self.joint_buf, self.joint_mem),
(self.transform_buf, self.transform_mem),
(self.material_buf, self.material_mem),
(self.light_buf, self.light_mem),
(self.shadow_buf, self.shadow_mem),
(self.tile_light_idx_buf, self.tile_light_idx_mem),
(self.tile_info_buf, self.tile_info_mem),
):
if buf:
vk.vkDestroyBuffer(device, buf, None)
if mem:
vk.vkFreeMemory(device, mem, None)
for layout in (self.joint_layout, self.ssbo_layout):
if layout:
vk.vkDestroyDescriptorSetLayout(device, layout, None)
for pool in (self.joint_pool, self.ssbo_pool):
if pool:
vk.vkDestroyDescriptorPool(device, pool, None)
if self.placeholder_cubemap_sampler:
vk.vkDestroySampler(device, self.placeholder_cubemap_sampler, None)
if self.placeholder_cubemap_view:
vk.vkDestroyImageView(device, self.placeholder_cubemap_view, None)
if self.placeholder_cubemap_img:
vk.vkDestroyImage(device, self.placeholder_cubemap_img, None)
if self.placeholder_cubemap_mem:
vk.vkFreeMemory(device, self.placeholder_cubemap_mem, None)