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