Source code for simvx.graphics.renderer.depth_prepass

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