"""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,
)