Source code for simvx.graphics.renderer.outline_pass

"""Selection outline rendering — backface hull expansion technique.

Renders selected objects with front-face culling and slight vertex expansion
along normals, producing a coloured outline around selected geometry. Operates
within the existing render pass (no stencil required).
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

import numpy as np
import vulkan as vk

from .._types import TRANSFORM_DTYPE
from ..gpu.memory import create_buffer, upload_numpy
from ..gpu.pipeline import create_shader_module
from ..materials.shader_compiler import compile_shader

if TYPE_CHECKING:
    from .._types import MeshHandle

__all__ = ["OutlinePass"]

log = logging.getLogger(__name__)


[docs] class OutlinePass: """Draws coloured outlines around selected objects using backface hull expansion. Technique: render selected meshes with front-face culling and vertices expanded along their normals. Only the "rim" of the expanded mesh is visible, creating an outline effect. Works without stencil support. Attributes: outline_colour: RGBA outline colour (default: orange). outline_width: Controls vertex expansion magnitude (default: 3.0). enabled: Toggle outline rendering on/off. """ 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._ready = False # Outline transform SSBO (separate from main scene transforms) self._transform_buf: Any = None self._transform_mem: Any = None self._ssbo_layout: Any = None self._ssbo_pool: Any = None self._ssbo_set: Any = None self._max_selected = 256 self.outline_colour: tuple[float, float, float, float] = (1.0, 0.5, 0.0, 1.0) self.outline_width: float = 3.0 self.enabled: bool = True
[docs] def setup(self) -> None: """Create outline pipeline and GPU resources.""" e = self._engine device = e.ctx.device phys = e.ctx.physical_device # Transform SSBO for selected objects buf_size = self._max_selected * TRANSFORM_DTYPE.itemsize self._transform_buf, self._transform_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 (reuse ssbo_layout for compatibility — only binding 0 is read) 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._transform_buf, buf_size) # Compile shaders shader_dir = e.shader_dir vert_spv = compile_shader(shader_dir / "outline.vert") frag_spv = compile_shader(shader_dir / "outline.frag") self._vert_module = create_shader_module(device, vert_spv) self._frag_module = create_shader_module(device, frag_spv) # Create pipeline self._pipeline, self._pipeline_layout = _create_outline_pipeline( device, self._vert_module, self._frag_module, e.render_pass, self._ssbo_layout, self.outline_colour, ) self._ready = True log.debug("Outline pass initialized (max %d selected objects)", self._max_selected)
[docs] def set_outline_colour(self, colour: tuple[float, float, float, float]) -> None: """Update outline colour. Requires pipeline recreation.""" if colour == self.outline_colour: return self.outline_colour = colour if not self._ready: return # Recreate pipeline with new specialization constants 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._pipeline, self._pipeline_layout = _create_outline_pipeline( device, self._vert_module, self._frag_module, self._engine.render_pass, self._ssbo_layout, self.outline_colour, )
[docs] def render( self, cmd: Any, selected_instances: list[tuple[MeshHandle, np.ndarray, int]], view_proj_data: bytes, registry: Any, extent: tuple[int, int], ) -> None: """Record outline draw commands for selected objects. Args: cmd: Active command buffer (inside render pass). selected_instances: List of (mesh_handle, transform_4x4, material_id) for selected objects. view_proj_data: Pre-packed view+proj push constant bytes (128 bytes, column-major). registry: MeshRegistry for vertex/index buffer lookup. extent: Framebuffer (width, height). """ if not self._ready or not self.enabled or not selected_instances: return count = min(len(selected_instances), self._max_selected) # Upload transforms for selected objects transforms = np.zeros(count, dtype=TRANSFORM_DTYPE) for i in range(count): mesh_handle, transform, material_id = selected_instances[i] if not transform.flags["C_CONTIGUOUS"]: transform = np.ascontiguousarray(transform) model_mat = transform if transform.shape == (4, 4) else transform.T model_mat_transposed = np.ascontiguousarray(model_mat.T) transforms[i]["model"] = model_mat_transposed model_3x3 = model_mat[:3, :3] try: inv_model_3x3 = np.linalg.inv(model_3x3).T normal_mat = np.eye(4, dtype=np.float32) normal_mat[:3, :3] = inv_model_3x3 transforms[i]["normal_mat"] = np.ascontiguousarray(normal_mat.T) except np.linalg.LinAlgError: transforms[i]["normal_mat"] = model_mat_transposed transforms[i]["material_index"] = material_id upload_numpy(self._engine.ctx.device, self._transform_mem, transforms) # Set 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]) # Bind outline pipeline vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline) # Bind transform SSBO vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline_layout, 0, 1, [self._ssbo_set], 0, None, ) # Push view+proj matrices ffi = vk.ffi cbuf = ffi.new("char[]", view_proj_data) vk._vulkan.lib.vkCmdPushConstants( cmd, self._pipeline_layout, vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, 0, len(view_proj_data), cbuf, ) # Group by mesh and draw mesh_groups: dict[int, list[int]] = {} for i in range(count): mesh_handle = selected_instances[i][0] mesh_groups.setdefault(mesh_handle.id, []).append(i) for _, indices in mesh_groups.items(): mesh_handle = selected_instances[indices[0]][0] vb, ib = registry.get_buffers(mesh_handle) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [vb], [0]) vk.vkCmdBindIndexBuffer(cmd, ib, 0, vk.VK_INDEX_TYPE_UINT32) for idx in indices: vk.vkCmdDrawIndexed(cmd, mesh_handle.index_count, 1, 0, 0, idx)
[docs] def cleanup(self) -> None: """Release all GPU resources.""" 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._transform_buf: vk.vkDestroyBuffer(device, self._transform_buf, None) if self._transform_mem: vk.vkFreeMemory(device, self._transform_mem, None) self._ready = False
def _create_outline_pipeline( device: Any, vert_module: Any, frag_module: Any, render_pass: Any, ssbo_layout: Any, outline_colour: tuple[float, float, float, float], ) -> tuple[Any, Any]: """Create the outline graphics pipeline. Uses front-face culling so only the expanded backfaces are visible, producing a solid-colour outline around the original mesh. Depth test enabled but depth write disabled so outlines layer on top of geometry. Specialization constants set the outline colour at pipeline creation. """ ffi = vk.ffi # Push constant: 2x mat4 (view + proj) = 128 bytes push_range = ffi.new("VkPushConstantRange*") push_range.stageFlags = vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT push_range.offset = 0 push_range.size = 128 # Pipeline layout set_layouts = ffi.new("VkDescriptorSetLayout[1]", [ssbo_layout]) layout_ci = ffi.new("VkPipelineLayoutCreateInfo*") layout_ci.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO 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}") pipeline_layout = layout_out[0] pi = ffi.new("VkGraphicsPipelineCreateInfo*") pi.sType = vk.VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO # Specialization constants for fragment shader outline colour spec_data = np.array(list(outline_colour), dtype=np.float32) spec_data_bytes = spec_data.tobytes() spec_buf = ffi.new("char[]", spec_data_bytes) spec_entries = ffi.new("VkSpecializationMapEntry[4]") for i in range(4): spec_entries[i].constantID = i spec_entries[i].offset = i * 4 spec_entries[i].size = 4 spec_info = ffi.new("VkSpecializationInfo*") spec_info.mapEntryCount = 4 spec_info.pMapEntries = spec_entries spec_info.dataSize = len(spec_data_bytes) spec_info.pData = spec_buf # 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 = 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 = frag_module stages[1].pName = main_name stages[1].pSpecializationInfo = spec_info pi.stageCount = 2 pi.pStages = stages # Vertex input — same as forward pass (position + normal + uv = 32 bytes) binding_desc = ffi.new("VkVertexInputBindingDescription*") binding_desc.binding = 0 binding_desc.stride = 32 binding_desc.inputRate = vk.VK_VERTEX_INPUT_RATE_VERTEX attr_descs = ffi.new("VkVertexInputAttributeDescription[3]") attr_descs[0].location = 0 attr_descs[0].binding = 0 attr_descs[0].format = vk.VK_FORMAT_R32G32B32_SFLOAT attr_descs[0].offset = 0 attr_descs[1].location = 1 attr_descs[1].binding = 0 attr_descs[1].format = vk.VK_FORMAT_R32G32B32_SFLOAT attr_descs[1].offset = 12 attr_descs[2].location = 2 attr_descs[2].binding = 0 attr_descs[2].format = vk.VK_FORMAT_R32G32_SFLOAT attr_descs[2].offset = 24 vi = ffi.new("VkPipelineVertexInputStateCreateInfo*") vi.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO vi.vertexBindingDescriptionCount = 1 vi.pVertexBindingDescriptions = binding_desc vi.vertexAttributeDescriptionCount = 3 vi.pVertexAttributeDescriptions = attr_descs 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 (dynamic) vps = ffi.new("VkPipelineViewportStateCreateInfo*") vps.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO vps.viewportCount = 1 vps.pViewports = ffi.new("VkViewport*") vps.scissorCount = 1 vps.pScissors = ffi.new("VkRect2D*") pi.pViewportState = vps # Rasterization — FRONT face culling (backface hull technique) 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_FRONT_BIT rs.frontFace = vk.VK_FRONT_FACE_CLOCKWISE 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 # Depth — test enabled, write disabled (outline draws over geometry) dss = ffi.new("VkPipelineDepthStencilStateCreateInfo*") dss.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO dss.depthTestEnable = 1 dss.depthWriteEnable = 0 dss.depthCompareOp = vk.VK_COMPARE_OP_LESS_OR_EQUAL pi.pDepthStencilState = dss # Colour blend — alpha blend for smooth edges blend_att = ffi.new("VkPipelineColorBlendAttachmentState*") blend_att.blendEnable = 1 blend_att.srcColorBlendFactor = vk.VK_BLEND_FACTOR_SRC_ALPHA blend_att.dstColorBlendFactor = vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA blend_att.colorBlendOp = vk.VK_BLEND_OP_ADD blend_att.srcAlphaBlendFactor = vk.VK_BLEND_FACTOR_ONE blend_att.dstAlphaBlendFactor = vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA blend_att.alphaBlendOp = vk.VK_BLEND_OP_ADD blend_att.colorWriteMask = 0xF cb = ffi.new("VkPipelineColorBlendStateCreateInfo*") cb.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO cb.attachmentCount = 1 cb.pAttachments = blend_att 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 = 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}") log.debug("Outline pipeline created (colour=%.1f,%.1f,%.1f,%.1f)", *outline_colour) return pipeline_out[0], pipeline_layout