"""Post-processing pass — HDR tone mapping, FXAA, bloom, DoF, motion blur, film grain, vignette, chromatic aberration.
"""
from __future__ import annotations
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,
color_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.color_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.color_view,
imageLayout=vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
)
bloom_info = vk.VkDescriptorImageInfo(
sampler=self._sampler,
imageView=rt.color_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.color_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,
color_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.color_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