Source code for simvx.graphics.renderer.particle_pass

"""GPU particle rendering: camera-facing billboards via SSBO."""

import logging
from typing import Any

import numpy as np
import vulkan as vk

from ..gpu.memory import create_buffer, upload_numpy
from ..gpu.pipeline import PipelineSpec, build_pipeline
from .pass_helpers import load_shader_modules

__all__ = ["ParticlePass"]

log = logging.getLogger(__name__)

# Must match PARTICLE_DTYPE from core
_PARTICLE_GPU_STRIDE = 16 * 4  # 16 floats × 4 bytes = 64 bytes
MAX_PARTICLES = 10_000

[docs] class ParticlePass: """Renders particles as camera-facing billboards. Each particle is a 6-vertex quad (2 triangles) expanded in the vertex shader. Particle data is uploaded to an SSBO each frame. """ def __init__(self, engine: Any): self._engine = engine self._pipeline: Any = None self._pipeline_layout: Any = None self._vert_module: Any = None self._frag_module: Any = None self._ssbo_layout: Any = None self._ssbo_pool: Any = None self._ssbo_set: Any = None self._particle_buf: Any = None self._particle_mem: Any = None self._ready = False
[docs] def setup(self) -> None: e = self._engine device = e.ctx.device phys = e.ctx.physical_device # Particle SSBO buf_size = MAX_PARTICLES * _PARTICLE_GPU_STRIDE self._particle_buf, self._particle_mem = create_buffer( device, phys, buf_size, vk.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, vk.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | vk.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, ) # Descriptor set for particle SSBO from ..gpu.descriptors import ( allocate_descriptor_set, create_descriptor_pool, create_ssbo_layout, write_ssbo_descriptor, ) self._ssbo_layout = create_ssbo_layout(device, binding_count=1) self._ssbo_pool = create_descriptor_pool(device, max_sets=1) self._ssbo_set = allocate_descriptor_set(device, self._ssbo_pool, self._ssbo_layout) write_ssbo_descriptor(device, self._ssbo_set, 0, self._particle_buf, buf_size) # Shaders self._vert_module, self._frag_module = load_shader_modules( device, e.shader_dir, "particle.vert", "particle.frag", ) # Pipeline self._create_pipeline(device, e.render_pass, e.extent) self._ready = True log.debug("Particle pass initialized (max %d particles)", MAX_PARTICLES)
[docs] def rebuild_pipeline(self, render_pass: Any) -> None: """Recreate the particle pipeline against a different render pass (e.g. HDR).""" if not self._ready: return device = self._engine.ctx.device if self._pipeline: vk.vkDestroyPipeline(device, self._pipeline, None) if self._pipeline_layout: vk.vkDestroyPipelineLayout(device, self._pipeline_layout, None) self._create_pipeline(device, render_pass, self._engine.extent)
def _create_pipeline(self, device: Any, render_pass: Any, extent: tuple[int, int]) -> None: """Create the particle pipeline: alpha blend, depth test/no-write, no vertex input. Declares its fixed-function state via :class:`PipelineSpec` and defers all cffi sub-struct plumbing (and lifetime management) to :func:`build_pipeline`. Geometry is shader-generated (6 verts/particle), so ``vertex_stride=0``. ``front_face`` is left at the spec default (CCW); the original hand-rolled pipeline set CLOCKWISE but with ``cull_mode=NONE`` front-face is inert, so this is GPU-equivalent and the render stays pixel-identical. """ spec = PipelineSpec( name="particle", topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, vertex_stride=0, # billboard quad generated in the vertex shader cull_mode=vk.VK_CULL_MODE_NONE, # billboards face camera depth_test=True, depth_write=False, # particles blend over geometry depth_compare=vk.VK_COMPARE_OP_LESS_OR_EQUAL, blend="alpha", dst_alpha_factor=vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, set_layouts=(self._ssbo_layout,), push_size=96, # mat4 view_proj + vec3 right + pad + vec3 up + pad push_stages=vk.VK_SHADER_STAGE_VERTEX_BIT, # Thin G-buffer: share the HDR pass's second attachment, mask writes. attachment_count=(2 if self._engine.gbuffer_active else 1), ) self._pipeline, self._pipeline_layout = build_pipeline( device, spec, render_pass, extent, vert_module=self._vert_module, frag_module=self._frag_module, )
[docs] def render( self, cmd: Any, particle_data: np.ndarray, view_proj: np.ndarray, camera_right: np.ndarray, camera_up: np.ndarray, extent: tuple[int, int], ) -> None: """Record particle draw commands.""" if not self._ready or len(particle_data) == 0: return count = min(len(particle_data), MAX_PARTICLES) upload_numpy(self._engine.ctx.device, self._particle_mem, particle_data[:count]) # Push constants: mat4 view_proj (64) + vec3 camera_right + pad (16) + vec3 camera_up + pad (16) = 96 pc = np.zeros(24, dtype=np.float32) pc[:16] = view_proj.T.ravel() # Transpose for GLSL column-major pc[16:19] = camera_right pc[20:23] = camera_up pc_bytes = pc.tobytes() ffi = vk.ffi cbuf = ffi.new("char[]", pc_bytes) # Viewport + scissor vk_vp = vk.VkViewport( x=0.0, y=0.0, width=float(extent[0]), height=float(extent[1]), minDepth=0.0, maxDepth=1.0, ) vk.vkCmdSetViewport(cmd, 0, 1, [vk_vp]) scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=extent[0], height=extent[1]), ) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) 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._ssbo_set], 0, None, ) vk._vulkan.lib.vkCmdPushConstants( cmd, self._pipeline_layout, vk.VK_SHADER_STAGE_VERTEX_BIT, 0, len(pc_bytes), cbuf, ) # 6 vertices per particle (billboard quad) vk.vkCmdDraw(cmd, count * 6, 1, 0, 0)
[docs] def cleanup(self) -> None: if not self._ready: return device = self._engine.ctx.device for obj, fn in [ (self._pipeline, vk.vkDestroyPipeline), (self._pipeline_layout, vk.vkDestroyPipelineLayout), (self._vert_module, vk.vkDestroyShaderModule), (self._frag_module, vk.vkDestroyShaderModule), (self._ssbo_layout, vk.vkDestroyDescriptorSetLayout), (self._ssbo_pool, vk.vkDestroyDescriptorPool), ]: if obj: fn(device, obj, None) if self._particle_buf: vk.vkDestroyBuffer(device, self._particle_buf, None) if self._particle_mem: vk.vkFreeMemory(device, self._particle_mem, None) self._ready = False