Source code for simvx.graphics.renderer.point_shadow_pass

"""Point and spot light shadow map rendering pass.

Point lights use a 6-face cubemap (rendered as 6 separate passes with
different view matrices).  Spot lights use a single 2D depth texture
with a perspective projection matching the cone angle.

Shadow depth textures are registered in the bindless texture array so
the forward fragment shader can sample them via integer index.
"""

import logging
import math
from typing import Any

import numpy as np
import vulkan as vk

from ..gpu.pipeline import PipelineSpec, build_pipeline, create_shader_module
from ..materials.shader_compiler import compile_shader
from ..types import ALPHA_CUTOFF
from .vertex_layouts import POSITION_ONLY_BINDINGS, POSITION_UV_BINDINGS

__all__ = ["PointShadowPass"]

log = logging.getLogger(__name__)

POINT_SHADOW_SIZE = 512
SPOT_SHADOW_SIZE = 1024
DEPTH_FORMAT = vk.VK_FORMAT_D32_SFLOAT
COLOR_FORMAT = vk.VK_FORMAT_R32_SFLOAT

def _cutoff_caster_slots(instances: list, materials: np.ndarray | None) -> list[int]:
    """Return the per-instance slots whose material is alpha-tested.

    Empty (byte-identical opaque path) when no material array is supplied or no
    instance uses ``blend="cutoff"``.
    """
    if materials is None or len(materials) == 0:
        return []
    n = len(materials)
    slots: list[int] = []
    for i, (_mh, _t, mat_id, _vid, _rl) in enumerate(instances):
        if 0 <= mat_id < n and materials[mat_id]["alpha_mode"] == ALPHA_CUTOFF:
            slots.append(i)
    return slots


# 6 cubemap face directions: +X, -X, +Y, -Y, +Z, -Z
_CUBE_FACE_TARGETS = [
    (np.array([1, 0, 0], dtype=np.float32), np.array([0, -1, 0], dtype=np.float32)),  # +X
    (np.array([-1, 0, 0], dtype=np.float32), np.array([0, -1, 0], dtype=np.float32)),  # -X
    (np.array([0, 1, 0], dtype=np.float32), np.array([0, 0, 1], dtype=np.float32)),  # +Y
    (np.array([0, -1, 0], dtype=np.float32), np.array([0, 0, -1], dtype=np.float32)),  # -Y
    (np.array([0, 0, 1], dtype=np.float32), np.array([0, -1, 0], dtype=np.float32)),  # +Z
    (np.array([0, 0, -1], dtype=np.float32), np.array([0, -1, 0], dtype=np.float32)),  # -Z
]


[docs] class PointShadowPass: """Renders depth from point/spot light POVs into shadow map textures. Point lights: 6-face atlas (6 x POINT_SHADOW_SIZE side-by-side). Spot lights: Single 2D depth texture (SPOT_SHADOW_SIZE x SPOT_SHADOW_SIZE). Uses a colour attachment (R32_SFLOAT) to store linear distance from light, plus a depth attachment for correct Z-testing during rendering. """ __slots__ = ( "_engine", "_point_render_pass", "_point_framebuffer", "_point_color_image", "_point_color_memory", "_point_color_view", "_point_depth_image", "_point_depth_memory", "_point_depth_view", "_point_sampler", "_point_texture_index", "_spot_render_pass", "_spot_framebuffer", "_spot_color_image", "_spot_color_memory", "_spot_color_view", "_spot_depth_image", "_spot_depth_memory", "_spot_depth_view", "_spot_sampler", "_spot_texture_index", "_pipeline", "_pipeline_layout", "_vert_module", "_frag_module", "_cutoff_pipeline", "_cutoff_layout", "_cutoff_vert", "_cutoff_frag", "_ready", ) def __init__(self, engine: Any): for slot in self.__slots__: object.__setattr__(self, slot, None) self._engine = engine self._point_texture_index = -1 self._spot_texture_index = -1 self._ready = False
[docs] def setup(self, ssbo_layout: Any) -> None: """Initialize point and spot shadow map resources.""" e = self._engine device = e.ctx.device phys = e.ctx.physical_device # -- Point shadow: 6-face atlas (colour R32F + depth D32F) -- atlas_w = POINT_SHADOW_SIZE * 6 atlas_h = POINT_SHADOW_SIZE self._point_render_pass = _create_colour_depth_pass(device) self._point_color_image, self._point_color_memory, self._point_color_view = _create_render_target( device, phys, atlas_w, atlas_h, COLOR_FORMAT, vk.VK_IMAGE_ASPECT_COLOR_BIT ) self._point_depth_image, self._point_depth_memory, self._point_depth_view = _create_render_target( device, phys, atlas_w, atlas_h, DEPTH_FORMAT, vk.VK_IMAGE_ASPECT_DEPTH_BIT ) self._point_framebuffer = _create_framebuffer( device, self._point_render_pass, [self._point_color_view, self._point_depth_view], atlas_w, atlas_h, ) self._point_sampler = _create_shadow_sampler(device) # Register point shadow map in bindless texture array from ..gpu.descriptors import write_texture_descriptor if not e.texture_descriptor_set: e._init_texture_system() self._point_texture_index = e._next_texture_index write_texture_descriptor( device, e.texture_descriptor_set, self._point_texture_index, self._point_color_view, self._point_sampler, ) e._next_texture_index += 1 # -- Spot shadow: single 2D (colour R32F + depth D32F) -- self._spot_render_pass = _create_colour_depth_pass(device) self._spot_color_image, self._spot_color_memory, self._spot_color_view = _create_render_target( device, phys, SPOT_SHADOW_SIZE, SPOT_SHADOW_SIZE, COLOR_FORMAT, vk.VK_IMAGE_ASPECT_COLOR_BIT ) self._spot_depth_image, self._spot_depth_memory, self._spot_depth_view = _create_render_target( device, phys, SPOT_SHADOW_SIZE, SPOT_SHADOW_SIZE, DEPTH_FORMAT, vk.VK_IMAGE_ASPECT_DEPTH_BIT ) self._spot_framebuffer = _create_framebuffer( device, self._spot_render_pass, [self._spot_color_view, self._spot_depth_view], SPOT_SHADOW_SIZE, SPOT_SHADOW_SIZE, ) self._spot_sampler = _create_shadow_sampler(device) self._spot_texture_index = e._next_texture_index write_texture_descriptor( device, e.texture_descriptor_set, self._spot_texture_index, self._spot_color_view, self._spot_sampler, ) e._next_texture_index += 1 # -- Shadow pipeline (shared for point and spot) -- shader_dir = e.shader_dir vert_spv = compile_shader(shader_dir / "shadow_point.vert") frag_spv = compile_shader(shader_dir / "shadow_point.frag") self._vert_module = create_shader_module(device, vert_spv) self._frag_module = create_shader_module(device, frag_spv) self._pipeline, self._pipeline_layout = _create_point_shadow_pipeline( device, self._vert_module, self._frag_module, self._point_render_pass, ssbo_layout, (POINT_SHADOW_SIZE, POINT_SHADOW_SIZE), ) # Cutout caster pipeline: position + shading (UV) streams plus # the bindless texture set for the albedo alpha discard. Setup cost only; # zero per-frame cost when no cutout caster is present. cutoff_vert_spv = compile_shader(shader_dir / "shadow_point_cutoff.vert") cutoff_frag_spv = compile_shader(shader_dir / "shadow_point_cutoff.frag") self._cutoff_vert = create_shader_module(device, cutoff_vert_spv) self._cutoff_frag = create_shader_module(device, cutoff_frag_spv) self._cutoff_pipeline, self._cutoff_layout = _create_point_shadow_cutoff_pipeline( device, self._cutoff_vert, self._cutoff_frag, self._point_render_pass, ssbo_layout, e.texture_descriptor_layout, (POINT_SHADOW_SIZE, POINT_SHADOW_SIZE), ) self._ready = True log.debug( "Point/spot shadow pass initialized (point=%dx%d, spot=%dx%d)", atlas_w, atlas_h, SPOT_SHADOW_SIZE, SPOT_SHADOW_SIZE, )
[docs] @property def point_shadow_texture_index(self) -> int: """Bindless index of the point shadow atlas texture.""" return self._point_texture_index
[docs] @property def spot_shadow_texture_index(self) -> int: """Bindless index of the spot shadow depth texture.""" return self._spot_texture_index
[docs] def render_point_shadow( self, cmd: Any, light_pos: np.ndarray, light_range: float, instances: list, ssbo_set: Any, mesh_registry: Any, multimesh_draws: list | None = None, materials: np.ndarray | None = None, ) -> None: """Render 6 cubemap faces for a point light shadow. Each face is rendered to a horizontal slice of the point shadow atlas. Linear distance from light is written to the R32F colour attachment. MultiMesh blocks are drawn via instanced draws (first_instance=base). Alpha-tested casters (``blend="cutoff"``) are routed through the cutout pipeline so their shadow shows the cutout; with none present the opaque draw set is byte-identical to the plain opaque pass. """ if not self._ready or (not instances and not multimesh_draws): return cutoff_slots = _cutoff_caster_slots(instances, materials) cutoff_lookup = set(cutoff_slots) atlas_w = POINT_SHADOW_SIZE * 6 atlas_h = POINT_SHADOW_SIZE near = 0.05 # 90-degree perspective projection (square faces) proj = _perspective_vulkan(math.radians(90.0), 1.0, near, light_range) # Begin render pass with clear clears = [ vk.VkClearValue(color=vk.VkClearColorValue(float32=[1.0, 1.0, 1.0, 1.0])), vk.VkClearValue(depthStencil=vk.VkClearDepthStencilValue(depth=1.0, stencil=0)), ] rp_info = vk.VkRenderPassBeginInfo( renderPass=self._point_render_pass, framebuffer=self._point_framebuffer, renderArea=vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=atlas_w, height=atlas_h), ), clearValueCount=2, pClearValues=clears, ) vk.vkCmdBeginRenderPass(cmd, rp_info, vk.VK_SUBPASS_CONTENTS_INLINE) vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline_layout, 0, 1, [ssbo_set], 0, None, ) # Group instances by mesh for batched drawing (opaque casters only; # cutout casters are drawn separately through the cutout pipeline). mesh_groups: dict[int, list[int]] = {} for i, (mesh_handle, _, _, _, _) in enumerate(instances): if i in cutoff_lookup: continue mesh_groups.setdefault(mesh_handle.id, []).append(i) ffi = vk.ffi for face in range(6): # A previous face's cutout draws leave the cutout pipeline bound; # restore the opaque pipeline. Skipped when no cutout caster present # so the opaque path stays byte-identical. if cutoff_slots: vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline) # Viewport for this face in the atlas vk_vp = vk.VkViewport( x=float(face * POINT_SHADOW_SIZE), y=0.0, width=float(POINT_SHADOW_SIZE), height=float(POINT_SHADOW_SIZE), minDepth=0.0, maxDepth=1.0, ) vk.vkCmdSetViewport(cmd, 0, 1, [vk_vp]) scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=face * POINT_SHADOW_SIZE, y=0), extent=vk.VkExtent2D(width=POINT_SHADOW_SIZE, height=POINT_SHADOW_SIZE), ) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) # Build light view matrix for this face target, up = _CUBE_FACE_TARGETS[face] view = _look_at(light_pos, light_pos + target, up) vp = (proj @ view).T # Transpose for GLSL column-major # Push constants: mat4 light_vp (64 bytes) + vec4 light_pos_far (16 bytes) pc_bytes = np.ascontiguousarray(vp).tobytes() light_pos_far = np.array([*light_pos[:3], light_range], dtype=np.float32) pc_bytes += light_pos_far.tobytes() cbuf = ffi.new("char[]", pc_bytes) vk._vulkan.lib.vkCmdPushConstants( cmd, self._pipeline_layout, vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, 0, 80, cbuf, ) # Draw per-instance meshes for _mesh_id, indices in mesh_groups.items(): mesh_handle = instances[indices[0]][0] bufs = mesh_registry.get_buffers(mesh_handle) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [bufs.position], [0]) vk.vkCmdBindIndexBuffer(cmd, bufs.index, 0, vk.VK_INDEX_TYPE_UINT32) for idx in indices: vk.vkCmdDrawIndexed(cmd, mesh_handle.index_count, 1, 0, 0, idx) # Draw MultiMesh blocks: one instanced draw per block (first_instance=base). if multimesh_draws: for draw in multimesh_draws: mm_mesh, mm_base, mm_count = draw[0], draw[1], draw[2] bufs = mesh_registry.get_buffers(mm_mesh) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [bufs.position], [0]) vk.vkCmdBindIndexBuffer(cmd, bufs.index, 0, vk.VK_INDEX_TYPE_UINT32) vk.vkCmdDrawIndexed(cmd, mm_mesh.index_count, mm_count, 0, 0, mm_base) # Cutout casters: cutout pipeline + bindless texture set, # per-instance draws (dynamically-uniform material index). if cutoff_slots: self._draw_cutoff(cmd, pc_bytes, cutoff_slots, instances, ssbo_set, mesh_registry) vk.vkCmdEndRenderPass(cmd)
def _draw_cutoff( self, cmd: Any, pc_bytes: bytes, cutoff_slots: list[int], instances: list, ssbo_set: Any, mesh_registry: Any, ) -> None: """Draw the alpha-tested (cutout) casters for one face/spot view.""" tex_ds = self._engine.texture_descriptor_set vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._cutoff_pipeline) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._cutoff_layout, 0, 1, [ssbo_set], 0, None ) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._cutoff_layout, 1, 1, [tex_ds], 0, None ) cbuf = vk.ffi.new("char[]", pc_bytes) vk._vulkan.lib.vkCmdPushConstants( cmd, self._cutoff_layout, vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, 0, 80, cbuf, ) for idx in cutoff_slots: mesh_handle = instances[idx][0] bufs = mesh_registry.get_buffers(mesh_handle) vk.vkCmdBindVertexBuffers(cmd, 0, 2, [bufs.position, bufs.shading], [0, 0]) vk.vkCmdBindIndexBuffer(cmd, bufs.index, 0, vk.VK_INDEX_TYPE_UINT32) vk.vkCmdDrawIndexed(cmd, mesh_handle.index_count, 1, 0, 0, idx)
[docs] def render_spot_shadow( self, cmd: Any, light_pos: np.ndarray, light_dir: np.ndarray, fov: float, light_range: float, instances: list, ssbo_set: Any, mesh_registry: Any, multimesh_draws: list | None = None, materials: np.ndarray | None = None, ) -> None: """Render a single perspective shadow map for a spot light. Args: light_pos: World-space position of the spot light. light_dir: Normalized direction the spot light points. fov: Outer cone angle in degrees (used as projection FOV). light_range: Maximum range of the spot light. multimesh_draws: Optional MultiMesh draw records for instanced shadow draws. materials: Renderer material SSBO array; alpha-tested casters route through the cutout pipeline. """ if not self._ready or (not instances and not multimesh_draws): return cutoff_slots = _cutoff_caster_slots(instances, materials) cutoff_lookup = set(cutoff_slots) near = 0.05 # Use outer cone angle * 2 as FOV (cone angle is half-angle) proj_fov = math.radians(min(fov * 2.0, 179.0)) proj = _perspective_vulkan(proj_fov, 1.0, near, light_range) # Build view matrix looking along light_dir light_dir_n = light_dir / np.linalg.norm(light_dir) up = np.array([0, 1, 0], dtype=np.float32) if abs(np.dot(light_dir_n, up)) > 0.99: up = np.array([1, 0, 0], dtype=np.float32) view = _look_at(light_pos, light_pos + light_dir_n, up) vp = (proj @ view).T # Transpose for column-major # Begin render pass clears = [ vk.VkClearValue(color=vk.VkClearColorValue(float32=[1.0, 1.0, 1.0, 1.0])), vk.VkClearValue(depthStencil=vk.VkClearDepthStencilValue(depth=1.0, stencil=0)), ] rp_info = vk.VkRenderPassBeginInfo( renderPass=self._spot_render_pass, framebuffer=self._spot_framebuffer, renderArea=vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=SPOT_SHADOW_SIZE, height=SPOT_SHADOW_SIZE), ), clearValueCount=2, pClearValues=clears, ) vk.vkCmdBeginRenderPass(cmd, rp_info, vk.VK_SUBPASS_CONTENTS_INLINE) vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline_layout, 0, 1, [ssbo_set], 0, None, ) # Viewport/scissor vk_vp = vk.VkViewport( x=0.0, y=0.0, width=float(SPOT_SHADOW_SIZE), height=float(SPOT_SHADOW_SIZE), minDepth=0.0, maxDepth=1.0, ) vk.vkCmdSetViewport(cmd, 0, 1, [vk_vp]) scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=SPOT_SHADOW_SIZE, height=SPOT_SHADOW_SIZE), ) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) # Push constants: mat4 light_vp + vec4 light_pos_far ffi = vk.ffi pc_bytes = np.ascontiguousarray(vp).tobytes() light_pos_far = np.array([*light_pos[:3], light_range], dtype=np.float32) pc_bytes += light_pos_far.tobytes() cbuf = ffi.new("char[]", pc_bytes) vk._vulkan.lib.vkCmdPushConstants( cmd, self._pipeline_layout, vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, 0, 80, cbuf, ) # Draw per-instance meshes (opaque casters only; cutout casters go # through the cutout pipeline below). mesh_groups: dict[int, list[int]] = {} for i, (mesh_handle, _, _, _, _) in enumerate(instances): if i in cutoff_lookup: continue mesh_groups.setdefault(mesh_handle.id, []).append(i) for _mesh_id, indices in mesh_groups.items(): mesh_handle = instances[indices[0]][0] bufs = mesh_registry.get_buffers(mesh_handle) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [bufs.position], [0]) vk.vkCmdBindIndexBuffer(cmd, bufs.index, 0, vk.VK_INDEX_TYPE_UINT32) for idx in indices: vk.vkCmdDrawIndexed(cmd, mesh_handle.index_count, 1, 0, 0, idx) # MultiMesh blocks: one instanced draw per block (first_instance=base). if multimesh_draws: for draw in multimesh_draws: mm_mesh, mm_base, mm_count = draw[0], draw[1], draw[2] bufs = mesh_registry.get_buffers(mm_mesh) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [bufs.position], [0]) vk.vkCmdBindIndexBuffer(cmd, bufs.index, 0, vk.VK_INDEX_TYPE_UINT32) vk.vkCmdDrawIndexed(cmd, mm_mesh.index_count, mm_count, 0, 0, mm_base) # Cutout casters. if cutoff_slots: self._draw_cutoff(cmd, pc_bytes, cutoff_slots, instances, ssbo_set, mesh_registry) vk.vkCmdEndRenderPass(cmd)
[docs] def get_spot_vp_matrix( self, light_pos: np.ndarray, light_dir: np.ndarray, fov: float, light_range: float, ) -> np.ndarray: """Compute the VP matrix for a spot light (for fragment shader sampling).""" near = 0.05 proj_fov = math.radians(min(fov * 2.0, 179.0)) proj = _perspective_vulkan(proj_fov, 1.0, near, light_range) light_dir_n = light_dir / np.linalg.norm(light_dir) up = np.array([0, 1, 0], dtype=np.float32) if abs(np.dot(light_dir_n, up)) > 0.99: up = np.array([1, 0, 0], dtype=np.float32) view = _look_at(light_pos, light_pos + light_dir_n, up) return (proj @ view).T # Transposed for GLSL column-major
[docs] def cleanup(self) -> None: """Release all GPU resources.""" if not self._ready: return device = self._engine.ctx.device # Destroy in reverse creation order for obj, fn in [ (self._pipeline, vk.vkDestroyPipeline), (self._pipeline_layout, vk.vkDestroyPipelineLayout), (self._cutoff_pipeline, vk.vkDestroyPipeline), (self._cutoff_layout, vk.vkDestroyPipelineLayout), (self._cutoff_vert, vk.vkDestroyShaderModule), (self._cutoff_frag, vk.vkDestroyShaderModule), (self._vert_module, vk.vkDestroyShaderModule), (self._frag_module, vk.vkDestroyShaderModule), # Point resources (self._point_framebuffer, vk.vkDestroyFramebuffer), (self._point_color_view, vk.vkDestroyImageView), (self._point_color_image, vk.vkDestroyImage), (self._point_depth_view, vk.vkDestroyImageView), (self._point_depth_image, vk.vkDestroyImage), (self._point_sampler, vk.vkDestroySampler), (self._point_render_pass, vk.vkDestroyRenderPass), # Spot resources (self._spot_framebuffer, vk.vkDestroyFramebuffer), (self._spot_color_view, vk.vkDestroyImageView), (self._spot_color_image, vk.vkDestroyImage), (self._spot_depth_view, vk.vkDestroyImageView), (self._spot_depth_image, vk.vkDestroyImage), (self._spot_sampler, vk.vkDestroySampler), (self._spot_render_pass, vk.vkDestroyRenderPass), ]: if obj: fn(device, obj, None) for mem in [ self._point_color_memory, self._point_depth_memory, self._spot_color_memory, self._spot_depth_memory, ]: if mem: vk.vkFreeMemory(device, mem, None) self._ready = False
# ============================================================================= # Helpers # ============================================================================= def _look_at(eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray: """Build a right-handed look-at view matrix (row-major).""" f = target - eye f = f / np.linalg.norm(f) r = np.cross(f, up) r = r / np.linalg.norm(r) u = np.cross(r, f) view = np.eye(4, dtype=np.float32) view[0, :3] = r view[1, :3] = u view[2, :3] = -f view[:3, 3] = -view[:3, :3] @ eye return view def _perspective_vulkan(fov_y: float, aspect: float, near: float, far: float) -> np.ndarray: """Build a Vulkan perspective projection matrix (depth [0,1], Y-flip).""" f = 1.0 / math.tan(fov_y * 0.5) proj = np.zeros((4, 4), dtype=np.float32) proj[0, 0] = f / aspect proj[1, 1] = -f # Vulkan Y-flip proj[2, 2] = far / (near - far) proj[2, 3] = (near * far) / (near - far) proj[3, 2] = -1.0 return proj def _create_render_target( device: Any, phys: Any, width: int, height: int, fmt: int, aspect: int, ) -> tuple[Any, Any, Any]: """Create an image + memory + view for a render target.""" from ..gpu.memory import _find_memory_type usage = vk.VK_IMAGE_USAGE_SAMPLED_BIT if aspect == vk.VK_IMAGE_ASPECT_COLOR_BIT: usage |= vk.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT else: usage |= vk.VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT img_info = vk.VkImageCreateInfo( imageType=vk.VK_IMAGE_TYPE_2D, format=fmt, extent=vk.VkExtent3D(width=width, height=height, depth=1), mipLevels=1, arrayLayers=1, samples=vk.VK_SAMPLE_COUNT_1_BIT, tiling=vk.VK_IMAGE_TILING_OPTIMAL, usage=usage, sharingMode=vk.VK_SHARING_MODE_EXCLUSIVE, initialLayout=vk.VK_IMAGE_LAYOUT_UNDEFINED, ) image = vk.vkCreateImage(device, img_info, None) mem_reqs = vk.vkGetImageMemoryRequirements(device, image) alloc_info = vk.VkMemoryAllocateInfo( allocationSize=mem_reqs.size, memoryTypeIndex=_find_memory_type( phys, mem_reqs.memoryTypeBits, vk.VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, ), ) memory = vk.vkAllocateMemory(device, alloc_info, None) vk.vkBindImageMemory(device, image, memory, 0) view_ci = vk.VkImageViewCreateInfo( image=image, viewType=vk.VK_IMAGE_VIEW_TYPE_2D, format=fmt, subresourceRange=vk.VkImageSubresourceRange( aspectMask=aspect, baseMipLevel=0, levelCount=1, baseArrayLayer=0, layerCount=1, ), ) view = vk.vkCreateImageView(device, view_ci, None) return image, memory, view def _create_shadow_sampler(device: Any) -> Any: """Create a linear-filter, clamp-to-border sampler for shadow maps.""" sampler_ci = vk.VkSamplerCreateInfo( magFilter=vk.VK_FILTER_LINEAR, minFilter=vk.VK_FILTER_LINEAR, addressModeU=vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER, addressModeV=vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER, addressModeW=vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER, borderColor=vk.VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE, compareEnable=vk.VK_FALSE, anisotropyEnable=vk.VK_FALSE, unnormalizedCoordinates=vk.VK_FALSE, mipmapMode=vk.VK_SAMPLER_MIPMAP_MODE_NEAREST, ) return vk.vkCreateSampler(device, sampler_ci, None) def _create_colour_depth_pass(device: Any) -> Any: """Create a render pass with R32F colour + D32F depth for linear distance output.""" attachments = [ # Colour (R32F: stores linear distance) vk.VkAttachmentDescription( format=COLOR_FORMAT, samples=vk.VK_SAMPLE_COUNT_1_BIT, loadOp=vk.VK_ATTACHMENT_LOAD_OP_CLEAR, storeOp=vk.VK_ATTACHMENT_STORE_OP_STORE, stencilLoadOp=vk.VK_ATTACHMENT_LOAD_OP_DONT_CARE, stencilStoreOp=vk.VK_ATTACHMENT_STORE_OP_DONT_CARE, initialLayout=vk.VK_IMAGE_LAYOUT_UNDEFINED, finalLayout=vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, ), # Depth (D32F: for Z-testing) vk.VkAttachmentDescription( format=DEPTH_FORMAT, samples=vk.VK_SAMPLE_COUNT_1_BIT, loadOp=vk.VK_ATTACHMENT_LOAD_OP_CLEAR, storeOp=vk.VK_ATTACHMENT_STORE_OP_DONT_CARE, stencilLoadOp=vk.VK_ATTACHMENT_LOAD_OP_DONT_CARE, stencilStoreOp=vk.VK_ATTACHMENT_STORE_OP_DONT_CARE, initialLayout=vk.VK_IMAGE_LAYOUT_UNDEFINED, finalLayout=vk.VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, ), ] colour_ref = vk.VkAttachmentReference( attachment=0, layout=vk.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, ) depth_ref = vk.VkAttachmentReference( attachment=1, layout=vk.VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, ) subpass = vk.VkSubpassDescription( pipelineBindPoint=vk.VK_PIPELINE_BIND_POINT_GRAPHICS, colorAttachmentCount=1, pColorAttachments=[colour_ref], pDepthStencilAttachment=depth_ref, ) dependencies = [ vk.VkSubpassDependency( srcSubpass=vk.VK_SUBPASS_EXTERNAL, dstSubpass=0, srcStageMask=vk.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, srcAccessMask=vk.VK_ACCESS_SHADER_READ_BIT, dstStageMask=( vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | vk.VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT ), dstAccessMask=(vk.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | vk.VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT), ), vk.VkSubpassDependency( srcSubpass=0, dstSubpass=vk.VK_SUBPASS_EXTERNAL, srcStageMask=( vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | vk.VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT ), srcAccessMask=(vk.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | vk.VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT), dstStageMask=vk.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, dstAccessMask=vk.VK_ACCESS_SHADER_READ_BIT, ), ] create_info = vk.VkRenderPassCreateInfo( attachmentCount=2, pAttachments=attachments, subpassCount=1, pSubpasses=[subpass], dependencyCount=2, pDependencies=dependencies, ) render_pass = vk.vkCreateRenderPass(device, create_info, None) log.debug("Point shadow render pass created (colour+depth)") return render_pass def _create_framebuffer( device: Any, render_pass: Any, views: list, width: int, height: int, ) -> Any: """Create a framebuffer with the given image views.""" fb_ci = vk.VkFramebufferCreateInfo( renderPass=render_pass, attachmentCount=len(views), pAttachments=views, width=width, height=height, layers=1, ) return vk.vkCreateFramebuffer(device, fb_ci, None) def _create_point_shadow_pipeline( device: Any, vert_module: Any, frag_module: Any, render_pass: Any, ssbo_layout: Any, extent: tuple[int, int], ) -> tuple[Any, Any]: """Create the point/spot shadow rendering pipeline. Declares its fixed-function state via :class:`PipelineSpec` and defers all cffi sub-struct plumbing (and lifetime management) to :func:`build_pipeline`. The shaders are compiled at runtime (``compile_shader``), so the pre-created modules are passed directly rather than via SPIR-V paths in the spec. Push constants: mat4 light_vp (64 bytes) + vec4 light_pos_far (16 bytes) = 80 bytes (VERTEX | FRAGMENT). One R32F colour attachment output (linear distance). Front-face culling + depth bias (1.25/1.75) fight shadow acne; the front face is left at CLOCKWISE because culling is active (FRONT_BIT), so the winding is load-bearing rather than inert. """ spec = PipelineSpec( name="point_shadow", topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, vertex_bindings=POSITION_ONLY_BINDINGS, # depth-only: position stream alone cull_mode=vk.VK_CULL_MODE_FRONT_BIT, front_face=vk.VK_FRONT_FACE_CLOCKWISE, depth_bias=(1.25, 1.75), depth_test=True, depth_write=True, depth_compare=vk.VK_COMPARE_OP_LESS_OR_EQUAL, blend="opaque", # single R32F colour attachment, no blending set_layouts=(ssbo_layout,), push_size=80, # mat4 light_vp + vec4 light_pos_far push_stages=vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, ) return build_pipeline( device, spec, render_pass, extent, vert_module=vert_module, frag_module=frag_module, ) def _create_point_shadow_cutoff_pipeline( device: Any, vert_module: Any, frag_module: Any, render_pass: Any, ssbo_layout: Any, texture_layout: Any, extent: tuple[int, int], ) -> tuple[Any, Any]: """Create the alpha-tested (cutout) point/spot shadow pipeline. Identical fixed-function state to the opaque point/spot shadow pipeline (front-cull, same depth bias, single R32F colour attachment) so cutout casters line up with opaque casters, but binds the position + shading (UV) streams and the bindless texture set for the fragment's albedo alpha discard. Push range matches the opaque pipeline (80 bytes, VERTEX | FRAGMENT). """ spec = PipelineSpec( name="point_shadow_cutoff", topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, vertex_bindings=POSITION_UV_BINDINGS, # position + UV (no normal) cull_mode=vk.VK_CULL_MODE_FRONT_BIT, front_face=vk.VK_FRONT_FACE_CLOCKWISE, depth_bias=(1.25, 1.75), depth_test=True, depth_write=True, depth_compare=vk.VK_COMPARE_OP_LESS_OR_EQUAL, blend="opaque", # single R32F colour attachment, no blending set_layouts=(ssbo_layout, texture_layout), push_size=80, # mat4 light_vp + vec4 light_pos_far push_stages=vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, ) return build_pipeline( device, spec, render_pass, extent, vert_module=vert_module, frag_module=frag_module, )