"""Scratch depth prepass for two-phase Hi-Z occlusion culling.
Renders set A (the predicted occluders: instances visible last frame) depth-only
into a dedicated full-res D32 SAMPLABLE target, from the main camera's POV. The
mid-frame Hi-Z build then reduces THIS scratch depth into the pyramid that the
phase-2 cull tests set-B candidates against. Because the occluders are drawn and
the pyramid rebuilt within the same frame, a newly disoccluded object is admitted
the frame it becomes visible (no 1-frame pop).
Depth-only by construction: the render pass has zero colour attachments
(``_create_depth_only_pass``), and the pipeline reuses ``shadow.vert`` / ``shadow.frag``
(transform SSBO via ``gl_InstanceIndex`` == ``first_instance`` slot, single mat4
push constant) but pushes the CAMERA view-projection and applies NO depth bias.
Fully gated by ``Renderer._occlusion_culling_enabled``: created lazily on the
first occlusion-enabled frame (mirrors VelocityPass / HiZPass), so the default
path allocates nothing.
"""
import logging
from typing import Any
import numpy as np
import vulkan as vk
from ..gpu.memory import _find_memory_type
from ..gpu.pipeline import FORWARD_VERTEX_ATTRS, FORWARD_VERTEX_STRIDE, PipelineSpec, build_pipeline
from .pass_helpers import load_shader_modules
__all__ = ["DepthPrepass"]
log = logging.getLogger(__name__)
_DEPTH_FORMAT = vk.VK_FORMAT_D32_SFLOAT
def _create_depth_only_pass(device: Any) -> Any:
"""Depth-only render pass: clear + store, final DEPTH_STENCIL_READ_ONLY_OPTIMAL.
The final layout matches what ``HiZPass`` binds for its mip0 source descriptor
(``VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL``), so the scratch depth is
sampled by the mid-frame Hi-Z build with no extra transition.
"""
depth_attachment = 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_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_DEPTH_STENCIL_READ_ONLY_OPTIMAL,
)
depth_ref = vk.VkAttachmentReference(
attachment=0,
layout=vk.VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL,
)
subpass = vk.VkSubpassDescription(
pipelineBindPoint=vk.VK_PIPELINE_BIND_POINT_GRAPHICS,
colorAttachmentCount=0,
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_EARLY_FRAGMENT_TESTS_BIT,
dstAccessMask=vk.VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT,
),
vk.VkSubpassDependency(
srcSubpass=0,
dstSubpass=vk.VK_SUBPASS_EXTERNAL,
srcStageMask=vk.VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT,
srcAccessMask=vk.VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT,
dstStageMask=vk.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
dstAccessMask=vk.VK_ACCESS_SHADER_READ_BIT,
),
]
return vk.vkCreateRenderPass(
device,
vk.VkRenderPassCreateInfo(
attachmentCount=1,
pAttachments=[depth_attachment],
subpassCount=1,
pSubpasses=[subpass],
dependencyCount=2,
pDependencies=dependencies,
),
None,
)
[docs]
class DepthPrepass:
"""Owns the scratch D32 depth target + depth-only pipelines for phase 1."""
def __init__(self, engine: Any) -> None:
self._engine = engine
self._ready = False
self._width = 0
self._height = 0
self._render_pass: Any = None
self._depth_image: Any = None
self._depth_memory: Any = None
self._depth_view: Any = None
self._framebuffer: Any = None
self._pipeline: Any = None # back-face cull (opaque)
self._pipeline_double: Any = None # cull off (double-sided)
self._pipeline_layout: Any = None
self._pipeline_layout_double: Any = None
self._vert_module: Any = None
self._frag_module: Any = None
# -- views ----------------------------------------------------------------
[docs]
@property
def depth_view(self) -> Any:
"""Scratch depth view (DEPTH_STENCIL_READ_ONLY after the pass) for the Hi-Z build."""
return self._depth_view
[docs]
@property
def depth_image(self) -> Any:
return self._depth_image
[docs]
@property
def pipeline(self) -> Any:
return self._pipeline
[docs]
@property
def pipeline_double(self) -> Any:
return self._pipeline_double
[docs]
@property
def pipeline_layout(self) -> Any:
return self._pipeline_layout
[docs]
@property
def render_pass(self) -> Any:
return self._render_pass
# -- setup ----------------------------------------------------------------
[docs]
def setup(self, width: int, height: int, ssbo_layout: Any) -> None:
"""Allocate the scratch depth target + depth-only pipelines.
Called lazily by the renderer only when occlusion is first enabled, so
the off path never touches any of this.
"""
self._width, self._height = width, height
e = self._engine
device = e.ctx.device
# Depth-only render pass: clears + stores depth, transitions to
# DEPTH_STENCIL_READ_ONLY_OPTIMAL (HiZPass's mip0 reads that layout).
self._render_pass = _create_depth_only_pass(device)
self._create_depth_target(width, height)
self._vert_module, self._frag_module = load_shader_modules(
device,
e.shader_dir,
"shadow.vert",
"shadow.frag",
)
self._pipeline, self._pipeline_layout = self._create_pipeline(
(width, height), ssbo_layout, double_sided=False
)
self._pipeline_double, self._pipeline_layout_double = self._create_pipeline(
(width, height), ssbo_layout, double_sided=True
)
self._ready = True
log.debug("Depth prepass initialised (%dx%d)", width, height)
def _create_depth_target(self, width: int, height: int) -> None:
e = self._engine
device = e.ctx.device
img_info = vk.VkImageCreateInfo(
imageType=vk.VK_IMAGE_TYPE_2D,
format=_DEPTH_FORMAT,
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=vk.VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | vk.VK_IMAGE_USAGE_SAMPLED_BIT,
sharingMode=vk.VK_SHARING_MODE_EXCLUSIVE,
initialLayout=vk.VK_IMAGE_LAYOUT_UNDEFINED,
)
self._depth_image = vk.vkCreateImage(device, img_info, None)
mem_reqs = vk.vkGetImageMemoryRequirements(device, self._depth_image)
self._depth_memory = vk.vkAllocateMemory(
device,
vk.VkMemoryAllocateInfo(
allocationSize=mem_reqs.size,
memoryTypeIndex=_find_memory_type(
e.ctx.physical_device,
mem_reqs.memoryTypeBits,
vk.VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
),
),
None,
)
vk.vkBindImageMemory(device, self._depth_image, self._depth_memory, 0)
self._depth_view = vk.vkCreateImageView(
device,
vk.VkImageViewCreateInfo(
image=self._depth_image,
viewType=vk.VK_IMAGE_VIEW_TYPE_2D,
format=_DEPTH_FORMAT,
subresourceRange=vk.VkImageSubresourceRange(
aspectMask=vk.VK_IMAGE_ASPECT_DEPTH_BIT,
baseMipLevel=0,
levelCount=1,
baseArrayLayer=0,
layerCount=1,
),
),
None,
)
self._framebuffer = vk.vkCreateFramebuffer(
device,
vk.VkFramebufferCreateInfo(
renderPass=self._render_pass,
attachmentCount=1,
pAttachments=[self._depth_view],
width=width,
height=height,
layers=1,
),
None,
)
# -- record ---------------------------------------------------------------
[docs]
def render(self, cmd: Any, scene_renderer: Any, view_proj_T: np.ndarray) -> None:
"""Begin the scratch depth pass and draw set A (depth-only) via the scene renderer.
``view_proj_T`` is the column-major (already-transposed) camera
view-projection mat4 pushed to ``shadow.vert``. ``scene_renderer`` issues
the A-only opaque + double-sided draws from the phase-1-seeded final batch.
"""
if not self._ready:
return
clear = vk.VkClearValue(depthStencil=vk.VkClearDepthStencilValue(depth=1.0, stencil=0))
rp_begin = vk.VkRenderPassBeginInfo(
renderPass=self._render_pass,
framebuffer=self._framebuffer,
renderArea=vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=self._width, height=self._height)
),
clearValueCount=1,
pClearValues=[clear],
)
vk.vkCmdBeginRenderPass(cmd, rp_begin, vk.VK_SUBPASS_CONTENTS_INLINE)
scene_renderer.render_depth_prepass(cmd, self, view_proj_T)
vk.vkCmdEndRenderPass(cmd)
[docs]
def push_view_proj(self, cmd: Any, view_proj_T: np.ndarray) -> None:
"""Push the column-major camera VP mat4 to the depth-only vertex shader."""
cbuf = vk.ffi.new("char[]", np.ascontiguousarray(view_proj_T, dtype=np.float32).tobytes())
vk._vulkan.lib.vkCmdPushConstants(
cmd,
self._pipeline_layout,
vk.VK_SHADER_STAGE_VERTEX_BIT,
0,
64,
cbuf,
)
# -- pipeline -------------------------------------------------------------
def _create_pipeline(
self, extent: tuple[int, int], ssbo_layout: Any, *, double_sided: bool
) -> tuple[Any, Any]:
"""Build a depth-only pipeline declaratively via :func:`build_pipeline`.
Returns ``(pipeline, layout)``. Both pipelines share identical layout
state (1 SSBO set + a 64B vertex-only camera-VP push), so the two
layout handles are interchangeable; the renderer binds via
``self._pipeline_layout``.
Depth-only by construction: ``blend="none"`` selects zero colour
attachments, matching the colour-attachment-less render pass. Cull is
the only delta between the two pipelines (BACK for opaque, NONE for
double-sided).
"""
spec = PipelineSpec(
name=f"DepthPrepass (double_sided={double_sided})",
topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST,
vertex_stride=FORWARD_VERTEX_STRIDE,
vertex_attrs=FORWARD_VERTEX_ATTRS,
cull_mode=vk.VK_CULL_MODE_NONE if double_sided else vk.VK_CULL_MODE_BACK_BIT,
depth_test=True,
depth_write=True,
depth_compare=vk.VK_COMPARE_OP_LESS,
blend="none", # zero colour attachments (depth-only render pass)
set_layouts=(ssbo_layout,),
push_size=64, # one mat4 camera VP
push_stages=vk.VK_SHADER_STAGE_VERTEX_BIT,
)
return build_pipeline(
self._engine.ctx.device, spec, self._render_pass, extent,
vert_module=self._vert_module, frag_module=self._frag_module,
)
# -- resize / cleanup -----------------------------------------------------
[docs]
def resize(self, width: int, height: int) -> None:
if not self._ready:
return
device = self._engine.ctx.device
self._width, self._height = width, height
vk.vkDestroyFramebuffer(device, self._framebuffer, None)
vk.vkDestroyImageView(device, self._depth_view, None)
vk.vkDestroyImage(device, self._depth_image, None)
vk.vkFreeMemory(device, self._depth_memory, None)
self._create_depth_target(width, height)
# Pipelines use dynamic viewport/scissor and the same render pass, so they
# need no rebuild on resize.
[docs]
def cleanup(self) -> None:
if not self._ready:
return
device = self._engine.ctx.device
for obj, fn in [
(self._framebuffer, vk.vkDestroyFramebuffer),
(self._pipeline, vk.vkDestroyPipeline),
(self._pipeline_double, vk.vkDestroyPipeline),
(self._pipeline_layout, vk.vkDestroyPipelineLayout),
(self._pipeline_layout_double, vk.vkDestroyPipelineLayout),
(self._vert_module, vk.vkDestroyShaderModule),
(self._frag_module, vk.vkDestroyShaderModule),
(self._depth_view, vk.vkDestroyImageView),
(self._depth_image, vk.vkDestroyImage),
(self._render_pass, vk.vkDestroyRenderPass),
]:
if obj:
fn(device, obj, None)
if self._depth_memory:
vk.vkFreeMemory(device, self._depth_memory, None)
self._ready = False