Source code for simvx.graphics.renderer.render_target

"""Offscreen render target with colour + depth attachments."""

import logging
from typing import Any

import vulkan as vk

from ..gpu.memory import create_image, transition_image_layout
from .passes import create_hdr_reload_pass, create_offscreen_pass, create_overlay_pass

log = logging.getLogger(__name__)

__all__ = ["RenderTarget", "GBUFFER_FORMAT"]

# Thin G-buffer: one extra RGB10A2 colour attachment carrying the
# octahedral world normal (RG), roughness (B) and flags (A2). A2B10G10R10 packs
# shader ``.rgba`` -> R10/G10/B10/A2, the conventional RGB10A2 render target.
GBUFFER_FORMAT = vk.VK_FORMAT_A2B10G10R10_UNORM_PACK32

[docs] class RenderTarget: """Manages an offscreen render target for render-to-texture. The colour image is transitioned to ``initial_layout`` at construction so it is safe to sample (or bind to a descriptor) before the first render pass writes to it. The default :data:`VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL` matches the offscreen render pass's ``finalLayout``: the render pass itself uses ``initialLayout=UNDEFINED`` with ``LOAD_OP_CLEAR``, so the pre-transition is discarded harmlessly on the first frame. """ def __init__( self, device: Any, physical_device: Any, width: int, height: int, colour_format: int = vk.VK_FORMAT_R8G8B8A8_UNORM, use_depth: bool = True, samplable_depth: bool = False, *, initial_layout: int = vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, queue: Any = None, command_pool: Any = None, reload_pass: bool = False, gbuffer: bool = False, ) -> None: self.device = device self.width = width self.height = height self.colour_format = colour_format # Thin G-buffer: built only when a consumer is active. ``None`` on # every default target so the render pass has a single colour attachment # and existing pipelines/framebuffers are byte-identical. self.gbuffer_image: Any = None self.gbuffer_memory: Any = None self.gbuffer_view: Any = None # Colour attachment (samplable) self.colour_image, self.colour_memory = create_image( device, physical_device, width, height, colour_format, vk.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | vk.VK_IMAGE_USAGE_SAMPLED_BIT | vk.VK_IMAGE_USAGE_STORAGE_BIT | vk.VK_IMAGE_USAGE_TRANSFER_SRC_BIT | vk.VK_IMAGE_USAGE_TRANSFER_DST_BIT, ) self.colour_view = vk.vkCreateImageView(device, vk.VkImageViewCreateInfo( image=self.colour_image, viewType=vk.VK_IMAGE_VIEW_TYPE_2D, format=colour_format, subresourceRange=vk.VkImageSubresourceRange( aspectMask=vk.VK_IMAGE_ASPECT_COLOR_BIT, baseMipLevel=0, levelCount=1, baseArrayLayer=0, layerCount=1, ), ), None) # Depth attachment. ``reload_pass`` (the HDR target) adds TRANSFER_SRC so # the scene-read split can copy depth into a sampleable texture; # every other RenderTarget keeps the byte-identical original usage. depth_fmt = vk.VK_FORMAT_D32_SFLOAT if use_depth else 0 if use_depth: depth_usage = vk.VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | vk.VK_IMAGE_USAGE_SAMPLED_BIT if reload_pass: depth_usage |= vk.VK_IMAGE_USAGE_TRANSFER_SRC_BIT self.depth_image, self.depth_memory = create_image( device, physical_device, width, height, depth_fmt, depth_usage, ) self.depth_view = vk.vkCreateImageView(device, vk.VkImageViewCreateInfo( image=self.depth_image, viewType=vk.VK_IMAGE_VIEW_TYPE_2D, format=depth_fmt, subresourceRange=vk.VkImageSubresourceRange( aspectMask=vk.VK_IMAGE_ASPECT_DEPTH_BIT, baseMipLevel=0, levelCount=1, baseArrayLayer=0, layerCount=1, ), ), None) else: self.depth_image = self.depth_memory = self.depth_view = None # Thin G-buffer colour attachment. Only allocated when # ``gbuffer`` is set (a consumer is active); otherwise this target keeps a # single colour attachment and is byte-identical to today. gbuffer_fmt = GBUFFER_FORMAT if gbuffer else 0 if gbuffer: self.gbuffer_image, self.gbuffer_memory = create_image( device, physical_device, width, height, GBUFFER_FORMAT, vk.VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | vk.VK_IMAGE_USAGE_SAMPLED_BIT, ) self.gbuffer_view = vk.vkCreateImageView(device, vk.VkImageViewCreateInfo( image=self.gbuffer_image, viewType=vk.VK_IMAGE_VIEW_TYPE_2D, format=GBUFFER_FORMAT, subresourceRange=vk.VkImageSubresourceRange( aspectMask=vk.VK_IMAGE_ASPECT_COLOR_BIT, baseMipLevel=0, levelCount=1, baseArrayLayer=0, layerCount=1, ), ), None) # Render pass and framebuffer self.render_pass = create_offscreen_pass( device, colour_format, depth_fmt, samplable_depth=samplable_depth, gbuffer_format=gbuffer_fmt, ) attachments = [self.colour_view] if self.gbuffer_view: attachments.append(self.gbuffer_view) if self.depth_view: attachments.append(self.depth_view) self.framebuffer = vk.vkCreateFramebuffer(device, vk.VkFramebufferCreateInfo( renderPass=self.render_pass, attachmentCount=len(attachments), pAttachments=attachments, width=width, height=height, layers=1, ), None) # Overlay render pass: colour-only LOAD_OP_LOAD for drawing 2D on top of 3D self.overlay_render_pass = create_overlay_pass(device, colour_format) self.overlay_framebuffer = vk.vkCreateFramebuffer(device, vk.VkFramebufferCreateInfo( renderPass=self.overlay_render_pass, attachmentCount=1, pAttachments=[self.colour_view], width=width, height=height, layers=1, ), None) # HDR reload pass: colour + depth LOAD_OP_LOAD for the scene-read pass # split. Created only for the HDR target (``reload_pass=True``); # every other RenderTarget leaves these ``None`` (zero-cost). Requires depth. self.reload_render_pass: Any = None self.reload_framebuffer: Any = None if reload_pass and self.depth_view is not None: self.reload_render_pass = create_hdr_reload_pass( device, colour_format, depth_fmt, gbuffer_format=gbuffer_fmt, ) reload_attachments = [self.colour_view] if self.gbuffer_view: reload_attachments.append(self.gbuffer_view) reload_attachments.append(self.depth_view) self.reload_framebuffer = vk.vkCreateFramebuffer(device, vk.VkFramebufferCreateInfo( renderPass=self.reload_render_pass, attachmentCount=len(reload_attachments), pAttachments=reload_attachments, width=width, height=height, layers=1, ), None) # One-shot transition so the colour image is in a sampler-safe layout # before any render pass writes to it. Without this, a descriptor that # points at this RT (e.g. a disabled bloom pass's output) and a shader # access on the first frame trips a validation error because the image # is still in UNDEFINED. Skipped when initial_layout=UNDEFINED (caller # opts out) or when no queue/pool was provided (legacy callsite). if initial_layout != vk.VK_IMAGE_LAYOUT_UNDEFINED and queue is not None and command_pool is not None: transition_image_layout( device, queue, command_pool, self.colour_image, vk.VK_IMAGE_LAYOUT_UNDEFINED, initial_layout, )
[docs] def begin_frame_barrier(self, cmd: Any) -> None: """Order this frame's attachment writes after the previous frame's accesses. This target is a single shared instance reused across ``FRAMES_IN_FLIGHT``, so frame N's colour/depth clear races frame N-1's accesses to the same image. The offscreen pass's incoming ``EXTERNAL`` dependency (which does chain across submits via submission order) only reaches prior *attachment writes* at the attachment-output/fragment-test stages; it does not cover two hazards this barrier adds: * WAW visibility of frame N-1's ``storeOp`` write (its ``srcAccessMask`` is 0, and an ``oldLayout=UNDEFINED`` image barrier would discard), and * WAR against frame N-1's *fragment-shader reads* of this target (a later pass sampling it, or a TAA-style resolve reading it as history) -- ``FRAGMENT_SHADER`` is absent from the render pass's source scope entirely, so frame N's clear could overtake those reads. A global memory barrier carries both with no layout change and near-zero cost (usually a no-op execution dependency); it is the render-graph alternative to per-frame target duplication. Record it immediately before ``vkCmdBeginRenderPass``. """ attach_write = ( vk.VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | vk.VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT ) vk.vkCmdPipelineBarrier( cmd, vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | vk.VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT | vk.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, vk.VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | vk.VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT, 0, 1, [vk.VkMemoryBarrier(srcAccessMask=attach_write, dstAccessMask=attach_write)], 0, None, 0, None, )
[docs] def destroy(self) -> None: """Clean up all resources.""" if self.reload_framebuffer is not None: vk.vkDestroyFramebuffer(self.device, self.reload_framebuffer, None) vk.vkDestroyRenderPass(self.device, self.reload_render_pass, None) vk.vkDestroyFramebuffer(self.device, self.overlay_framebuffer, None) vk.vkDestroyRenderPass(self.device, self.overlay_render_pass, None) vk.vkDestroyFramebuffer(self.device, self.framebuffer, None) vk.vkDestroyRenderPass(self.device, self.render_pass, None) if self.depth_view: vk.vkDestroyImageView(self.device, self.depth_view, None) if self.depth_image: vk.vkDestroyImage(self.device, self.depth_image, None) if self.depth_memory: vk.vkFreeMemory(self.device, self.depth_memory, None) if self.gbuffer_view: vk.vkDestroyImageView(self.device, self.gbuffer_view, None) if self.gbuffer_image: vk.vkDestroyImage(self.device, self.gbuffer_image, None) if self.gbuffer_memory: vk.vkFreeMemory(self.device, self.gbuffer_memory, None) vk.vkDestroyImageView(self.device, self.colour_view, None) vk.vkDestroyImage(self.device, self.colour_image, None) vk.vkFreeMemory(self.device, self.colour_memory, None)