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