"""Post-processing pass — HDR tone mapping, FXAA, bloom, DoF, motion blur, film grain, vignette, chromatic aberration.
"""
import logging
import time
from typing import Any
import numpy as np
import vulkan as vk
from ..gpu.memory import create_buffer, create_sampler
from ..gpu.pipeline import create_shader_module
from ..materials.shader_compiler import compile_shader
from .bloom_pass import BloomPass
from .render_target import RenderTarget
__all__ = ["PostProcessPass"]
log = logging.getLogger(__name__)
# Push constant layout (52 bytes):
# vec2 screen_size 8
# float exposure 4
# uint flags 4
# float bloom_intensity 4
# float dof_focus_distance 4
# float dof_focus_range 4
# float dof_max_blur 4
# float grain_intensity 4
# float vignette_intensity 4
# float vignette_smoothness 4
# float chromatic_intensity 4
# float time 4
# float motion_blur_intensity 4
# uint motion_blur_samples 4
# (4 bytes padding for vec4 alignment)
# vec4 fog_colour 16 (rgb + density in alpha)
# vec4 fog_params 16 (start, end, mode, fog_enabled)
# Total = 96 bytes
_PC_SIZE = 96
# Flag bits
FLAG_FXAA = 1 << 0
FLAG_BLOOM = 1 << 1
FLAG_SSAO = 1 << 2
FLAG_DOF = 1 << 3
FLAG_GRAIN = 1 << 4
FLAG_VIGNETTE = 1 << 5
FLAG_CHROMATIC = 1 << 6
FLAG_MOTION_BLUR = 1 << 7
[docs]
class PostProcessPass:
"""Renders HDR scene to offscreen target, then tone-maps to swapchain.
The pass owns an HDR render target. When enabled:
- 3D scene renders to the HDR target (pre_render phase)
- Bloom extraction + blur (optional)
- Tone mapping + FXAA + cinematic effects renders to swapchain (main render pass)
"""
def __init__(self, engine: Any):
self._engine = engine
self._enabled = False
self._start_time = time.perf_counter()
# HDR render target
self._hdr_target: RenderTarget | None = None
# Tonemap pipeline
self._pipeline: Any = None
self._pipeline_layout: Any = None
self._vert_module: Any = None
self._frag_module: Any = None
# HDR + bloom + depth texture descriptors
self._sampler: Any = None
self._depth_sampler: Any = None
self._descriptor_pool: Any = None
self._descriptor_layout: Any = None
self._descriptor_set: Any = None
# Bloom
self._bloom_pass: BloomPass | None = None
self._bloom_enabled = False
self.bloom_intensity = 0.3
self.bloom_threshold = 1.0
# Settings — core
self.exposure = 1.0
self.fxaa_enabled = True
# Depth of Field
self.dof_enabled = False
self.dof_focus_distance = 0.5
self.dof_focus_range = 0.1
self.dof_max_blur = 6.0
# Film Grain
self.grain_enabled = False
self.grain_intensity = 0.05
# Vignette
self.vignette_enabled = False
self.vignette_intensity = 0.8
self.vignette_smoothness = 0.4
# Chromatic Aberration
self.chromatic_aberration_enabled = False
self.chromatic_aberration_intensity = 0.005
# SSAO
self.ssao_enabled = False
# Motion Blur
self._motion_blur_enabled = False
self.motion_blur_intensity = 1.0
self.motion_blur_samples = 8
# Fog (applied in tonemap shader, post-ACES, in LDR space)
self.fog_enabled = False
self.fog_colour: tuple[float, float, float] = (0.7, 0.8, 0.9)
self.fog_density: float = 0.03
self.fog_start: float = 10.0
self.fog_end: float = 100.0
self.fog_mode: float = 1.0 # 0=linear, 1=exp, 2=exp2
# Motion blur UBO: inv_vp(mat4=64) + prev_vp(mat4=64) = 128 bytes
self._mb_ubo_buf: Any = None
self._mb_ubo_mem: Any = None
self._prev_vp: np.ndarray = np.eye(4, dtype=np.float32)
self._has_prev_vp = False
@property
def enabled(self) -> bool:
return self._enabled
@property
def hdr_target(self) -> RenderTarget | None:
return self._hdr_target
@property
def bloom_enabled(self) -> bool:
return self._bloom_enabled
@bloom_enabled.setter
def bloom_enabled(self, value: bool) -> None:
self._bloom_enabled = value
@property
def motion_blur_enabled(self) -> bool:
return self._motion_blur_enabled
@motion_blur_enabled.setter
def motion_blur_enabled(self, value: bool) -> None:
self._motion_blur_enabled = value
def _build_flags(self) -> int:
"""Compute flags bitmask from current settings."""
flags = 0
if self.fxaa_enabled:
flags |= FLAG_FXAA
if self._bloom_enabled and self._bloom_pass:
flags |= FLAG_BLOOM
if self.ssao_enabled:
flags |= FLAG_SSAO
if self.dof_enabled:
flags |= FLAG_DOF
if self.grain_enabled:
flags |= FLAG_GRAIN
if self.vignette_enabled:
flags |= FLAG_VIGNETTE
if self.chromatic_aberration_enabled:
flags |= FLAG_CHROMATIC
if self._motion_blur_enabled and self._has_prev_vp:
flags |= FLAG_MOTION_BLUR
return flags
[docs]
def setup(self) -> None:
"""Initialize HDR target and tonemap pipeline."""
e = self._engine
device = e.ctx.device
w, h = e.extent
# Create HDR render target (16-bit float) with samplable depth for motion blur
self._hdr_target = RenderTarget(
device, e.ctx.physical_device, w, h,
colour_format=vk.VK_FORMAT_R16G16B16A16_SFLOAT,
use_depth=True,
samplable_depth=True,
)
# Samplers
self._sampler = create_sampler(device)
self._depth_sampler = self._create_depth_sampler(device)
# Motion blur UBO (inv_vp + prev_vp = 128 bytes)
self._mb_ubo_buf, self._mb_ubo_mem = create_buffer(
device, e.ctx.physical_device, 128,
vk.VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
vk.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | vk.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
)
# Descriptor layout: 4 combined image samplers (HDR + bloom + depth + SSAO) + 1 UBO (motion blur)
bindings = [
vk.VkDescriptorSetLayoutBinding(
binding=b,
descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
descriptorCount=1,
stageFlags=vk.VK_SHADER_STAGE_FRAGMENT_BIT,
)
for b in range(3)
]
bindings.append(vk.VkDescriptorSetLayoutBinding(
binding=3,
descriptorType=vk.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
descriptorCount=1,
stageFlags=vk.VK_SHADER_STAGE_FRAGMENT_BIT,
))
bindings.append(vk.VkDescriptorSetLayoutBinding(
binding=4,
descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
descriptorCount=1,
stageFlags=vk.VK_SHADER_STAGE_FRAGMENT_BIT,
))
self._descriptor_layout = vk.vkCreateDescriptorSetLayout(device,
vk.VkDescriptorSetLayoutCreateInfo(
bindingCount=len(bindings), pBindings=bindings,
), None)
# Descriptor pool: 4 samplers + 1 UBO in 1 set
pool_sizes = [
vk.VkDescriptorPoolSize(
type=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
descriptorCount=4,
),
vk.VkDescriptorPoolSize(
type=vk.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
descriptorCount=1,
),
]
self._descriptor_pool = vk.vkCreateDescriptorPool(device,
vk.VkDescriptorPoolCreateInfo(
maxSets=1, poolSizeCount=len(pool_sizes), pPoolSizes=pool_sizes,
), None)
# Allocate and write descriptor
sets = vk.vkAllocateDescriptorSets(device, vk.VkDescriptorSetAllocateInfo(
descriptorPool=self._descriptor_pool,
descriptorSetCount=1,
pSetLayouts=[self._descriptor_layout],
))
self._descriptor_set = sets[0]
# Write descriptors for HDR (0), bloom placeholder (1), depth (2)
self._write_descriptors(device)
# Compile tonemap shaders
shader_dir = e.shader_dir
vert_spv = compile_shader(shader_dir / "tonemap.vert")
frag_spv = compile_shader(shader_dir / "tonemap.frag")
self._vert_module = create_shader_module(device, vert_spv)
self._frag_module = create_shader_module(device, frag_spv)
# Create tonemap pipeline
self._create_pipeline(device, e.render_pass, (w, h))
# Initialize bloom pass
self._bloom_pass = BloomPass(self._engine)
self._bloom_pass.setup(self._hdr_target.colour_view)
self._bloom_pass.threshold = self.bloom_threshold
self._bloom_enabled = True
# Update bloom descriptor binding now that bloom is ready
self._update_bloom_descriptor()
self._enabled = True
log.debug("Post-processing pass initialized (%dx%d HDR, bloom=%s)", w, h, self._bloom_enabled)
def _create_depth_sampler(self, device: Any) -> Any:
"""Create a sampler suitable for depth texture sampling."""
sampler_ci = vk.VkSamplerCreateInfo(
magFilter=vk.VK_FILTER_LINEAR,
minFilter=vk.VK_FILTER_LINEAR,
mipmapMode=vk.VK_SAMPLER_MIPMAP_MODE_NEAREST,
addressModeU=vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
addressModeV=vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
addressModeW=vk.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
minLod=0.0,
maxLod=0.0,
)
return vk.vkCreateSampler(device, sampler_ci, None)
def _write_descriptors(self, device: Any) -> None:
"""Write all descriptor bindings (HDR, bloom, depth, motion blur UBO, SSAO)."""
rt = self._hdr_target
hdr_info = vk.VkDescriptorImageInfo(
sampler=self._sampler,
imageView=rt.colour_view,
imageLayout=vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
)
bloom_info = vk.VkDescriptorImageInfo(
sampler=self._sampler,
imageView=rt.colour_view, # placeholder until bloom setup
imageLayout=vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
)
depth_info = vk.VkDescriptorImageInfo(
sampler=self._depth_sampler,
imageView=rt.depth_view,
imageLayout=vk.VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL,
)
ssao_info = vk.VkDescriptorImageInfo(
sampler=self._sampler,
imageView=rt.colour_view, # placeholder until SSAO setup
imageLayout=vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
)
writes = [
vk.VkWriteDescriptorSet(
dstSet=self._descriptor_set, dstBinding=i, dstArrayElement=0,
descriptorCount=1,
descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
pImageInfo=[info],
)
for i, info in enumerate([hdr_info, bloom_info, depth_info])
]
# SSAO placeholder at binding 4
writes.append(vk.VkWriteDescriptorSet(
dstSet=self._descriptor_set, dstBinding=4, dstArrayElement=0,
descriptorCount=1,
descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
pImageInfo=[ssao_info],
))
# Motion blur UBO at binding 3
if self._mb_ubo_buf:
ubo_info = vk.VkDescriptorBufferInfo(
buffer=self._mb_ubo_buf, offset=0, range=128,
)
writes.append(vk.VkWriteDescriptorSet(
dstSet=self._descriptor_set, dstBinding=3, dstArrayElement=0,
descriptorCount=1,
descriptorType=vk.VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
pBufferInfo=[ubo_info],
))
vk.vkUpdateDescriptorSets(device, len(writes), writes, 0, None)
def _update_bloom_descriptor(self) -> None:
"""Update binding 1 with the bloom pass output image view."""
if not self._bloom_pass or not self._bloom_pass.bloom_image_view:
return
bloom_info = vk.VkDescriptorImageInfo(
sampler=self._sampler,
imageView=self._bloom_pass.bloom_image_view,
imageLayout=vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
)
vk.vkUpdateDescriptorSets(self._engine.ctx.device, 1, [vk.VkWriteDescriptorSet(
dstSet=self._descriptor_set, dstBinding=1, dstArrayElement=0,
descriptorCount=1,
descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
pImageInfo=[bloom_info],
)], 0, None)
[docs]
def update_ssao_descriptor(self, ao_view: Any) -> None:
"""Update binding 4 with the SSAO output image view."""
if not self._descriptor_set or not self._sampler:
return
# SSAO compute output stays in GENERAL layout; use GENERAL here to match.
ssao_info = vk.VkDescriptorImageInfo(
sampler=self._sampler,
imageView=ao_view,
imageLayout=vk.VK_IMAGE_LAYOUT_GENERAL,
)
vk.vkUpdateDescriptorSets(self._engine.ctx.device, 1, [vk.VkWriteDescriptorSet(
dstSet=self._descriptor_set, dstBinding=4, dstArrayElement=0,
descriptorCount=1,
descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
pImageInfo=[ssao_info],
)], 0, None)
[docs]
def update_motion_blur_matrices(self, view: np.ndarray, proj: np.ndarray) -> None:
"""Update the motion blur UBO with current inverse VP and previous VP.
Call once per frame before render_tonemap() with the current camera matrices.
Uses temporal smoothing to prevent sudden jumps from frame time variance.
"""
vp = (proj @ view).astype(np.float32)
try:
inv_vp = np.linalg.inv(vp).astype(np.float32)
except np.linalg.LinAlgError:
inv_vp = np.eye(4, dtype=np.float32)
# On first frame, set prev_vp = current to avoid a massive initial velocity spike
if not self._has_prev_vp:
self._prev_vp = vp.copy()
# Upload to GPU
if self._mb_ubo_mem:
inv_vp_t = np.ascontiguousarray(inv_vp.T, dtype=np.float32)
prev_vp_t = np.ascontiguousarray(self._prev_vp.T, dtype=np.float32)
from ..gpu.memory import upload_numpy
data = np.concatenate([inv_vp_t.ravel(), prev_vp_t.ravel()])
upload_numpy(self._engine.ctx.device, self._mb_ubo_mem, data)
# Store current VP for next frame
self._prev_vp = vp.copy()
self._has_prev_vp = True
def _create_pipeline(self, device: Any, render_pass: Any, extent: tuple[int, int]) -> None:
"""Create the tone mapping fullscreen pipeline."""
ffi = vk.ffi
push_range = ffi.new("VkPushConstantRange*")
push_range.stageFlags = vk.VK_SHADER_STAGE_FRAGMENT_BIT
push_range.offset = 0
push_range.size = _PC_SIZE
# Pipeline layout
layout_ci = ffi.new("VkPipelineLayoutCreateInfo*")
layout_ci.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO
set_layouts = ffi.new("VkDescriptorSetLayout[1]", [self._descriptor_layout])
layout_ci.setLayoutCount = 1
layout_ci.pSetLayouts = set_layouts
layout_ci.pushConstantRangeCount = 1
layout_ci.pPushConstantRanges = push_range
layout_out = ffi.new("VkPipelineLayout*")
result = vk._vulkan._callApi(
vk._vulkan.lib.vkCreatePipelineLayout,
device, layout_ci, ffi.NULL, layout_out,
)
if result != vk.VK_SUCCESS:
raise RuntimeError(f"vkCreatePipelineLayout failed: {result}")
self._pipeline_layout = layout_out[0]
# Pipeline create info
pi = ffi.new("VkGraphicsPipelineCreateInfo*")
pi.sType = vk.VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO
# Shader stages
stages = ffi.new("VkPipelineShaderStageCreateInfo[2]")
main_name = ffi.new("char[]", b"main")
stages[0].sType = vk.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO
stages[0].stage = vk.VK_SHADER_STAGE_VERTEX_BIT
stages[0].module = self._vert_module
stages[0].pName = main_name
stages[1].sType = vk.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO
stages[1].stage = vk.VK_SHADER_STAGE_FRAGMENT_BIT
stages[1].module = self._frag_module
stages[1].pName = main_name
pi.stageCount = 2
pi.pStages = stages
# No vertex input (fullscreen triangle generated in shader)
vi = ffi.new("VkPipelineVertexInputStateCreateInfo*")
vi.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO
pi.pVertexInputState = vi
# Input assembly
ia = ffi.new("VkPipelineInputAssemblyStateCreateInfo*")
ia.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO
ia.topology = vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST
pi.pInputAssemblyState = ia
# Viewport state
vps = ffi.new("VkPipelineViewportStateCreateInfo*")
vps.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO
vps.viewportCount = 1
viewport = ffi.new("VkViewport*")
viewport.width = float(extent[0])
viewport.height = float(extent[1])
viewport.maxDepth = 1.0
vps.pViewports = viewport
scissor = ffi.new("VkRect2D*")
scissor.extent.width = extent[0]
scissor.extent.height = extent[1]
vps.scissorCount = 1
vps.pScissors = scissor
pi.pViewportState = vps
# Rasterization
rs = ffi.new("VkPipelineRasterizationStateCreateInfo*")
rs.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO
rs.polygonMode = vk.VK_POLYGON_MODE_FILL
rs.lineWidth = 1.0
rs.cullMode = vk.VK_CULL_MODE_NONE
pi.pRasterizationState = rs
# Multisample
ms = ffi.new("VkPipelineMultisampleStateCreateInfo*")
ms.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO
ms.rasterizationSamples = vk.VK_SAMPLE_COUNT_1_BIT
pi.pMultisampleState = ms
# No depth test
dss = ffi.new("VkPipelineDepthStencilStateCreateInfo*")
dss.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO
dss.depthTestEnable = 0
dss.depthWriteEnable = 0
pi.pDepthStencilState = dss
# Colour blend (no blending)
cba = ffi.new("VkPipelineColorBlendAttachmentState*")
cba.colorWriteMask = (
vk.VK_COLOR_COMPONENT_R_BIT | vk.VK_COLOR_COMPONENT_G_BIT
| vk.VK_COLOR_COMPONENT_B_BIT | vk.VK_COLOR_COMPONENT_A_BIT
)
cb = ffi.new("VkPipelineColorBlendStateCreateInfo*")
cb.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO
cb.attachmentCount = 1
cb.pAttachments = cba
pi.pColorBlendState = cb
# Dynamic state
dyn_states = ffi.new("VkDynamicState[2]", [vk.VK_DYNAMIC_STATE_VIEWPORT, vk.VK_DYNAMIC_STATE_SCISSOR])
ds = ffi.new("VkPipelineDynamicStateCreateInfo*")
ds.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO
ds.dynamicStateCount = 2
ds.pDynamicStates = dyn_states
pi.pDynamicState = ds
pi.layout = self._pipeline_layout
pi.renderPass = render_pass
pipeline_out = ffi.new("VkPipeline*")
result = vk._vulkan._callApi(
vk._vulkan.lib.vkCreateGraphicsPipelines,
device, ffi.NULL, 1, pi, ffi.NULL, pipeline_out,
)
if result != vk.VK_SUCCESS:
raise RuntimeError(f"vkCreateGraphicsPipelines failed: {result}")
self._pipeline = pipeline_out[0]
[docs]
def begin_hdr_pass(self, cmd: Any) -> None:
"""Begin the HDR render pass (call before 3D rendering)."""
if not self._enabled or not self._hdr_target:
return
rt = self._hdr_target
cc = getattr(self._engine, "clear_colour", [0.0, 0.0, 0.0, 1.0])
clear_values = [
vk.VkClearValue(color=vk.VkClearColorValue(float32=cc)),
vk.VkClearValue(depthStencil=vk.VkClearDepthStencilValue(depth=1.0, stencil=0)),
]
rp_begin = vk.VkRenderPassBeginInfo(
renderPass=rt.render_pass,
framebuffer=rt.framebuffer,
renderArea=vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0),
extent=vk.VkExtent2D(width=rt.width, height=rt.height),
),
clearValueCount=len(clear_values),
pClearValues=clear_values,
)
vk.vkCmdBeginRenderPass(cmd, rp_begin, vk.VK_SUBPASS_CONTENTS_INLINE)
[docs]
def end_hdr_pass(self, cmd: Any) -> None:
"""End the HDR render pass."""
if not self._enabled:
return
vk.vkCmdEndRenderPass(cmd)
[docs]
def render_bloom(self, cmd: Any) -> None:
"""Execute bloom pass (extract + blur). Call after end_hdr_pass."""
if not self._bloom_enabled or not self._bloom_pass:
return
self._bloom_pass.threshold = self.bloom_threshold
self._bloom_pass.render(cmd)
[docs]
def render_tonemap(self, cmd: Any, width: int, height: int) -> None:
"""Render tone-mapped fullscreen quad to current render pass (swapchain)."""
if not self._enabled or not self._pipeline:
return
# Set viewport/scissor
vk_viewport = vk.VkViewport(
x=0.0, y=0.0,
width=float(width), height=float(height),
minDepth=0.0, maxDepth=1.0,
)
vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport])
scissor = vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0),
extent=vk.VkExtent2D(width=width, height=height),
)
vk.vkCmdSetScissor(cmd, 0, 1, [scissor])
# Bind pipeline and descriptor
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, [self._descriptor_set], 0, None,
)
# Build push constants (52 bytes)
flags = self._build_flags()
elapsed = time.perf_counter() - self._start_time
pc_data = bytearray(_PC_SIZE)
pc_data[0:8] = np.array([float(width), float(height)], dtype=np.float32).tobytes()
pc_data[8:12] = np.array([self.exposure], dtype=np.float32).tobytes()
pc_data[12:16] = np.array([flags], dtype=np.uint32).tobytes()
pc_data[16:20] = np.array([self.bloom_intensity], dtype=np.float32).tobytes()
pc_data[20:24] = np.array([self.dof_focus_distance], dtype=np.float32).tobytes()
pc_data[24:28] = np.array([self.dof_focus_range], dtype=np.float32).tobytes()
pc_data[28:32] = np.array([self.dof_max_blur], dtype=np.float32).tobytes()
pc_data[32:36] = np.array([self.grain_intensity], dtype=np.float32).tobytes()
pc_data[36:40] = np.array([self.vignette_intensity], dtype=np.float32).tobytes()
pc_data[40:44] = np.array([self.vignette_smoothness], dtype=np.float32).tobytes()
pc_data[44:48] = np.array([self.chromatic_aberration_intensity], dtype=np.float32).tobytes()
pc_data[48:52] = np.array([elapsed], dtype=np.float32).tobytes()
mb_intensity = max(0.0, min(2.0, self.motion_blur_intensity))
mb_samples = max(4, min(32, self.motion_blur_samples))
pc_data[52:56] = np.array([mb_intensity], dtype=np.float32).tobytes()
pc_data[56:60] = np.array([mb_samples], dtype=np.uint32).tobytes()
# bytes 60-63: padding for vec4 alignment (zeroed by bytearray init)
# Fog params (applied post-tonemap in LDR space)
fc = self.fog_colour
pc_data[64:80] = np.array([fc[0], fc[1], fc[2], self.fog_density], dtype=np.float32).tobytes()
fog_enabled_f = 1.0 if self.fog_enabled else 0.0
pc_data[80:96] = np.array([self.fog_start, self.fog_end, self.fog_mode, fog_enabled_f], dtype=np.float32).tobytes()
ffi = vk.ffi
cbuf = ffi.new("char[]", bytes(pc_data))
vk._vulkan.lib.vkCmdPushConstants(
cmd, self._pipeline_layout,
vk.VK_SHADER_STAGE_FRAGMENT_BIT,
0, _PC_SIZE, cbuf,
)
# Draw fullscreen triangle (3 vertices, no vertex buffer)
vk.vkCmdDraw(cmd, 3, 1, 0, 0)
[docs]
def resize(self, width: int, height: int) -> None:
"""Recreate HDR target and pipeline for new dimensions."""
if not self._enabled:
return
e = self._engine
device = e.ctx.device
# Destroy old resources
if self._pipeline:
vk.vkDestroyPipeline(device, self._pipeline, None)
if self._pipeline_layout:
vk.vkDestroyPipelineLayout(device, self._pipeline_layout, None)
if self._hdr_target:
self._hdr_target.destroy()
# Recreate with samplable depth
self._hdr_target = RenderTarget(
device, e.ctx.physical_device, width, height,
colour_format=vk.VK_FORMAT_R16G16B16A16_SFLOAT,
use_depth=True,
samplable_depth=True,
)
# Update descriptors (HDR + depth)
self._write_descriptors(device)
# Resize bloom pass
if self._bloom_pass:
self._bloom_pass.resize(width, height, self._hdr_target.colour_view)
self._update_bloom_descriptor()
self._create_pipeline(device, e.render_pass, (width, height))
[docs]
def cleanup(self) -> None:
"""Release all GPU resources."""
if not self._enabled:
return
device = self._engine.ctx.device
if self._bloom_pass:
self._bloom_pass.cleanup()
if self._pipeline:
vk.vkDestroyPipeline(device, self._pipeline, None)
if self._pipeline_layout:
vk.vkDestroyPipelineLayout(device, self._pipeline_layout, None)
if self._vert_module:
vk.vkDestroyShaderModule(device, self._vert_module, None)
if self._frag_module:
vk.vkDestroyShaderModule(device, self._frag_module, None)
if self._descriptor_pool:
vk.vkDestroyDescriptorPool(device, self._descriptor_pool, None)
if self._descriptor_layout:
vk.vkDestroyDescriptorSetLayout(device, self._descriptor_layout, None)
if self._sampler:
vk.vkDestroySampler(device, self._sampler, None)
if self._depth_sampler:
vk.vkDestroySampler(device, self._depth_sampler, None)
if self._mb_ubo_buf:
vk.vkDestroyBuffer(device, self._mb_ubo_buf, None)
if self._mb_ubo_mem:
vk.vkFreeMemory(device, self._mb_ubo_mem, None)
if self._hdr_target:
self._hdr_target.destroy()
self._enabled = False