Source code for simvx.graphics.renderer.draw2d_pass

"""2D drawing pass — renders Draw2D geometry using ui.vert + draw2d_fill.frag, plus MSDF text.

Fill fragments are modulated by the ``Light2DPass`` accumulation texture when
any ``PointLight2D`` is present in the scene (multiply blend with an ambient
floor). With no lights submitted, the composite is a bypass and fills render
with their unlit vertex colour.
"""

import logging
from typing import Any

import numpy as np
import vulkan as vk

from .._types import SHADER_DIR
from ..gpu.memory import create_buffer, upload_numpy
from .pass_helpers import create_sampler_descriptor_pool

__all__ = ["Draw2DPass"]

log = logging.getLogger(__name__)

# Pre-allocate buffers — sized for full terminal rendering (100x30 terminal
# with bitmap font can generate ~100K verts for character pixels + backgrounds)
MAX_FILL_VERTS = 131072   # 128K verts (4 MB)
MAX_LINE_VERTS = 32768    # 32K verts (1 MB)
VERTEX_STRIDE = 32        # pos(vec2) + uv(vec2) + colour(vec4)
MAX_FILL_INDICES = 196608  # 192K indices (768 KB)
MAX_TEXT_VERTS = 32768
MAX_TEXT_INDICES = 49152
MAX_TEX_VERTS = 16384
MAX_TEX_INDICES = 24576

# Fill push constant layout: vec2 screen_size + vec2 pad + vec4 ambient + ivec4 flags = 48 bytes
FILL_PUSH_SIZE = 48
# Ambient floor applied to unlit regions when any PointLight2D is submitted.
# Matches the dark-neutral brightness Godot's Light2D defaults to when the
# canvas_modulate ambient is left unspecified.
_DEFAULT_AMBIENT = (0.2, 0.2, 0.2, 1.0)

[docs] class Draw2DPass: """GPU pass that renders 2D fills (triangles), lines, and MSDF text from Draw2D buffers. Text rendering shares the TextPass's pipeline, descriptor set, and atlas — only the text vertex/index buffers are owned here (needed for per-batch scissor clipping). """ __slots__ = ( "_engine", "_text_pass", "_light2d_pass", "_fill_pipeline", "_fill_pipeline_layout", "_fill_desc_layout", "_fill_desc_pool", "_fill_desc_set", "_fill_desc_view", "_line_pipeline", "_line_pipeline_layout", "_vert_module", "_frag_module", "_line_frag_module", "_fill_vb", "_fill_vb_mem", "_fill_ib", "_fill_ib_mem", "_line_vb", "_line_vb_mem", "_text_vb", "_text_vb_mem", "_text_ib", "_text_ib_mem", "_tex_pipeline", "_tex_pipeline_layout", "_tex_frag_module", "_tex_vb", "_tex_vb_mem", "_tex_ib", "_tex_ib_mem", "_ready", ) def __init__(self, engine: Any, text_pass: Any = None, light2d_pass: Any = None): for slot in self.__slots__: object.__setattr__(self, slot, None) self._engine = engine self._text_pass = text_pass self._light2d_pass = light2d_pass self._ready = False
[docs] def setup(self, render_pass: Any = None, extent: tuple[int, int] | None = None) -> None: """Create pipelines and allocate GPU buffers. Args: render_pass: Vulkan render pass to compile pipelines against. Defaults to the engine's main (swapchain) render pass. extent: Framebuffer extent (width, height). Defaults to engine extent. """ e = self._engine device = e.ctx.device phys = e.ctx.physical_device rp = render_pass or e.render_pass ext = extent or e.extent # Compile shaders — fill uses draw2d_fill.frag (modulated by Light2D accum), # lines use the plain solid-colour frag (UI overlays are not lit). from ..gpu.pipeline import create_shader_module from ..materials.shader_compiler import compile_shader self._vert_module = create_shader_module(device, compile_shader(SHADER_DIR / "ui.vert")) self._frag_module = create_shader_module(device, compile_shader(SHADER_DIR / "draw2d_fill.frag")) self._line_frag_module = create_shader_module( device, compile_shader(SHADER_DIR / "ui_solid.frag"), ) # Fill pipeline uses set 0 = light accumulation sampler2D from # Light2DPass. The shader bypasses the texture sample entirely when the # has_lights push-constant flag is 0, so we only need a valid view # bound to keep Vulkan validation happy; Light2DPass.setup pre- # transitions its RT to SHADER_READ_ONLY_OPTIMAL for that reason. self._fill_desc_layout = _create_fill_descriptor_layout(device) self._fill_desc_pool, desc_sets = create_sampler_descriptor_pool( device, self._fill_desc_layout, ) self._fill_desc_set = desc_sets[0] if self._light2d_pass is not None: view = self._light2d_pass.get_light_texture_view() sampler = self._light2d_pass.get_light_sampler() _write_fill_descriptor(device, self._fill_desc_set, view, sampler) self._fill_desc_view = view # Fill pipeline (triangle topology) — custom layout with descriptor set + 48B push self._fill_pipeline, self._fill_pipeline_layout = _create_fill_pipeline( device, self._vert_module, self._frag_module, rp, ext, self._fill_desc_layout, ) # Line pipeline (line topology) — create via CFFI, shares ui.vert + ui_solid.frag self._line_pipeline, self._line_pipeline_layout = _create_line2d_pipeline( device, self._vert_module, self._line_frag_module, rp, ext, ) # Allocate host-visible buffers host_flags = ( vk.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | vk.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ) self._fill_vb, self._fill_vb_mem = create_buffer( device, phys, MAX_FILL_VERTS * VERTEX_STRIDE, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_flags, ) self._fill_ib, self._fill_ib_mem = create_buffer( device, phys, MAX_FILL_INDICES * 4, vk.VK_BUFFER_USAGE_INDEX_BUFFER_BIT, host_flags, ) self._line_vb, self._line_vb_mem = create_buffer( device, phys, MAX_LINE_VERTS * VERTEX_STRIDE, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_flags, ) # Text vertex/index buffers (geometry assembled per-batch for scissor clipping) self._text_vb, self._text_vb_mem = create_buffer( device, phys, MAX_TEXT_VERTS * VERTEX_STRIDE, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_flags, ) self._text_ib, self._text_ib_mem = create_buffer( device, phys, MAX_TEXT_INDICES * 4, vk.VK_BUFFER_USAGE_INDEX_BUFFER_BIT, host_flags, ) # Textured quad pipeline — uses bindless texture descriptor set from ..gpu.pipeline import create_shader_module from ..materials.shader_compiler import compile_shader tex_frag_spv = compile_shader(SHADER_DIR / "ui.frag") self._tex_frag_module = create_shader_module(device, tex_frag_spv) tex_layout = e.texture_descriptor_layout self._tex_pipeline, self._tex_pipeline_layout = _create_textured_ui_pipeline( device, self._vert_module, self._tex_frag_module, rp, ext, tex_layout, ) self._tex_vb, self._tex_vb_mem = create_buffer( device, phys, MAX_TEX_VERTS * VERTEX_STRIDE, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, host_flags, ) self._tex_ib, self._tex_ib_mem = create_buffer( device, phys, MAX_TEX_INDICES * 4, vk.VK_BUFFER_USAGE_INDEX_BUFFER_BIT, host_flags, ) self._ready = True
[docs] def render(self, cmd: Any, width: int, height: int, ui_width: int = 0, ui_height: int = 0, batches: list | None = None) -> None: """Render 2D geometry (fills, lines, text, textured quads). Args: batches: Pre-extracted batch list. If None, pulls from Draw2D singleton. """ if not self._ready: return from ..draw2d import Draw2D device = self._engine.ctx.device # UI coordinates may differ from framebuffer pixels (HiDPI / window vs framebuffer) uw = ui_width or width uh = ui_height or height screen = np.array([uw, uh], dtype=np.float32) # Refresh the light-accumulation descriptor if Light2DPass recreated its RT # (happens on window resize). Rare, so waitIdle is acceptable here. if self._light2d_pass is not None: current_view = self._light2d_pass.get_light_texture_view() if current_view != self._fill_desc_view: vk.vkDeviceWaitIdle(device) _write_fill_descriptor( device, self._fill_desc_set, current_view, self._light2d_pass.get_light_sampler(), ) self._fill_desc_view = current_view # Build fill push constants: vec2 screen + vec2 pad + vec4 ambient + ivec4(has_lights, 0, 0, 0) has_lights = 1 if ( self._light2d_pass is not None and self._light2d_pass.has_lights ) else 0 fill_push = _build_fill_push(uw, uh, _DEFAULT_AMBIENT, has_lights) vk_viewport = vk.VkViewport( x=0.0, y=0.0, width=float(width), height=float(height), minDepth=0.0, maxDepth=1.0, ) full_scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=width, height=height), ) # Get batches (each with optional clip rect) if batches is None: batches = Draw2D._get_batches() if not batches: return # Batched rendering: concatenate all data, upload once, draw with offsets all_fill_verts = [] all_fill_indices = [] all_line_verts = [] all_text_verts = [] all_text_indices = [] draw_cmds = [] # (clip, fi_start, fi_count, fv_offset, lv_start, lv_count, ti_start, ti_count, tv_offset) fv_total = 0 # fill vertex count so far fi_total = 0 # fill index count so far lv_total = 0 # line vertex count so far tv_total = 0 # text vertex count so far ti_total = 0 # text index count so far # Textured quads: concatenate into a single VB/IB shared across every # (batch, texture_id) tuple. Each entry in ``all_tex_draws`` is # (batch_idx, tex_id, tex_i_start, tex_i_count, tex_v_offset) so the # second pass can issue per-texture draw calls with offsets into the # uploaded buffers — mirrors the fill / line / text flow. all_tex_verts: list[Any] = [] all_tex_indices: list[Any] = [] all_tex_draws: list[tuple[int, int, int, int, int]] = [] tv_tex_total = 0 ti_tex_total = 0 for batch_idx, (clip, fill_data, line_data, text_data, tex_draws) in enumerate(batches): fi_start = fi_total fi_count = 0 fv_offset = fv_total lv_start = lv_total lv_count = 0 ti_start = ti_total ti_count = 0 tv_offset = tv_total if fill_data is not None: verts, indices = fill_data all_fill_verts.append(verts) all_fill_indices.append(indices) fi_count = len(indices) fv_total += len(verts) fi_total += len(indices) if line_data is not None: all_line_verts.append(line_data) lv_count = len(line_data) lv_total += len(line_data) if text_data is not None: verts, indices = text_data all_text_verts.append(verts) all_text_indices.append(indices) ti_count = len(indices) tv_total += len(verts) ti_total += len(indices) for tex_id, tverts, tindices in (tex_draws or []): ni = len(tindices) if ni == 0 or len(tverts) == 0: continue all_tex_verts.append(tverts) all_tex_indices.append(tindices) all_tex_draws.append((batch_idx, tex_id, ti_tex_total, ni, tv_tex_total)) tv_tex_total += len(tverts) ti_tex_total += ni draw_cmds.append((clip, fi_start, fi_count, fv_offset, lv_start, lv_count, ti_start, ti_count, tv_offset)) # Single upload for all batches (clamped to buffer capacity) if all_fill_verts: combined_fv = np.concatenate(all_fill_verts) combined_fi = np.concatenate(all_fill_indices) if len(combined_fv) > MAX_FILL_VERTS: log.warning("Draw2D fill overflow: %d verts (max %d)", len(combined_fv), MAX_FILL_VERTS) combined_fv = combined_fv[:MAX_FILL_VERTS] combined_fi = combined_fi[:MAX_FILL_INDICES] if len(combined_fi) > MAX_FILL_INDICES: combined_fi = combined_fi[:MAX_FILL_INDICES] upload_numpy(device, self._fill_vb_mem, combined_fv) upload_numpy(device, self._fill_ib_mem, combined_fi) if all_line_verts: combined_lv = np.concatenate(all_line_verts) if len(combined_lv) > MAX_LINE_VERTS: log.warning("Draw2D line overflow: %d verts (max %d)", len(combined_lv), MAX_LINE_VERTS) combined_lv = combined_lv[:MAX_LINE_VERTS] upload_numpy(device, self._line_vb_mem, combined_lv) if all_text_verts: combined_tv = np.concatenate(all_text_verts) combined_ti = np.concatenate(all_text_indices) if len(combined_tv) > MAX_TEXT_VERTS: log.warning("Draw2D text overflow: %d verts (max %d)", len(combined_tv), MAX_TEXT_VERTS) combined_tv = combined_tv[:MAX_TEXT_VERTS] combined_ti = combined_ti[:MAX_TEXT_INDICES] if len(combined_ti) > MAX_TEXT_INDICES: combined_ti = combined_ti[:MAX_TEXT_INDICES] upload_numpy(device, self._text_vb_mem, combined_tv) upload_numpy(device, self._text_ib_mem, combined_ti) if all_tex_verts: combined_tex_v = np.concatenate(all_tex_verts) combined_tex_i = np.concatenate(all_tex_indices) if len(combined_tex_v) > MAX_TEX_VERTS: log.warning("Draw2D textured overflow: %d verts (max %d)", len(combined_tex_v), MAX_TEX_VERTS) combined_tex_v = combined_tex_v[:MAX_TEX_VERTS] combined_tex_i = combined_tex_i[:MAX_TEX_INDICES] if len(combined_tex_i) > MAX_TEX_INDICES: combined_tex_i = combined_tex_i[:MAX_TEX_INDICES] upload_numpy(device, self._tex_vb_mem, combined_tex_v) upload_numpy(device, self._tex_ib_mem, combined_tex_i) # Scale factor from UI coords to framebuffer pixels (for scissor rects) clip_sx = width / uw if uw > 0 else 1.0 clip_sy = height / uh if uh > 0 else 1.0 # Issue per-batch draw calls with offsets for batch_idx, ( clip, fi_start, fi_count, fv_offset, lv_start, lv_count, ti_start, ti_count, tv_offset, ) in enumerate(draw_cmds): if clip is not None: scissor = vk.VkRect2D( offset=vk.VkOffset2D(x=int(clip[0] * clip_sx), y=int(clip[1] * clip_sy)), extent=vk.VkExtent2D(width=int(clip[2] * clip_sx), height=int(clip[3] * clip_sy)), ) else: scissor = full_scissor if fi_count > 0: vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._fill_pipeline) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._fill_pipeline_layout, 0, 1, [self._fill_desc_set], 0, None, ) self._engine.push_constants(cmd, self._fill_pipeline_layout, fill_push) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._fill_vb], [0]) vk.vkCmdBindIndexBuffer(cmd, self._fill_ib, 0, vk.VK_INDEX_TYPE_UINT32) vk.vkCmdDrawIndexed(cmd, fi_count, 1, fi_start, fv_offset, 0) if lv_count > 0: vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._line_pipeline) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) self._engine.push_constants(cmd, self._line_pipeline_layout, screen.tobytes()) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._line_vb], [0]) vk.vkCmdDraw(cmd, lv_count, 1, lv_start, 0) if ti_count > 0 and self._text_pass and self._text_pass.atlas_version > 0: tp = self._text_pass vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, tp.pipeline) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, tp.pipeline_layout, 0, 1, [tp.descriptor_set], 0, None, ) text_pc = np.array([uw, uh, tp.px_range], dtype=np.float32) self._engine.push_constants(cmd, tp.pipeline_layout, text_pc.tobytes()) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._text_vb], [0]) vk.vkCmdBindIndexBuffer(cmd, self._text_ib, 0, vk.VK_INDEX_TYPE_UINT32) vk.vkCmdDrawIndexed(cmd, ti_count, 1, ti_start, tv_offset, 0) # Textured quads (bindless texture sampling via ui.frag). # ``all_tex_draws`` is a flat list of (batch_idx, tex_id, ti_start, # ti_count, tv_offset) entries with offsets into the pre-uploaded # shared tex VB/IB, so each texture draws from its own slice. if self._tex_pipeline: tex_desc = self._engine.texture_descriptor_set if tex_desc: pipeline_bound = False for tb_idx, tex_id, tex_i_start, tex_i_count, tex_v_offset in all_tex_draws: if tb_idx != batch_idx or tex_i_count == 0: continue if not pipeline_bound: vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._tex_pipeline) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) vk.vkCmdBindDescriptorSets( cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._tex_pipeline_layout, 0, 1, [tex_desc], 0, None, ) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._tex_vb], [0]) vk.vkCmdBindIndexBuffer(cmd, self._tex_ib, 0, vk.VK_INDEX_TYPE_UINT32) pipeline_bound = True tex_pc = np.array([uw, uh], dtype=np.float32) tex_pc_bytes = tex_pc.tobytes() + np.array([tex_id], dtype=np.int32).tobytes() self._engine.push_constants(cmd, self._tex_pipeline_layout, tex_pc_bytes) vk.vkCmdDrawIndexed(cmd, tex_i_count, 1, tex_i_start, tex_v_offset, 0)
# Draw2D._reset() is called at start of frame in app.py, before tree.draw()
[docs] def cleanup(self) -> None: if not self._ready: return device = self._engine.ctx.device for obj, fn in [ (self._fill_pipeline, vk.vkDestroyPipeline), (self._fill_pipeline_layout, vk.vkDestroyPipelineLayout), (self._line_pipeline, vk.vkDestroyPipeline), (self._line_pipeline_layout, vk.vkDestroyPipelineLayout), (self._tex_pipeline, vk.vkDestroyPipeline), (self._tex_pipeline_layout, vk.vkDestroyPipelineLayout), (self._vert_module, vk.vkDestroyShaderModule), (self._frag_module, vk.vkDestroyShaderModule), (self._line_frag_module, vk.vkDestroyShaderModule), (self._tex_frag_module, vk.vkDestroyShaderModule), (self._fill_vb, vk.vkDestroyBuffer), (self._fill_ib, vk.vkDestroyBuffer), (self._line_vb, vk.vkDestroyBuffer), (self._text_vb, vk.vkDestroyBuffer), (self._text_ib, vk.vkDestroyBuffer), (self._tex_vb, vk.vkDestroyBuffer), (self._tex_ib, vk.vkDestroyBuffer), (self._fill_desc_pool, vk.vkDestroyDescriptorPool), (self._fill_desc_layout, vk.vkDestroyDescriptorSetLayout), ]: if obj: fn(device, obj, None) for mem in [ self._fill_vb_mem, self._fill_ib_mem, self._line_vb_mem, self._text_vb_mem, self._text_ib_mem, self._tex_vb_mem, self._tex_ib_mem, ]: if mem: vk.vkFreeMemory(device, mem, None) self._ready = False
def _create_line2d_pipeline( device: Any, vert_module: Any, frag_module: Any, render_pass: Any, extent: tuple[int, int], ) -> tuple[Any, Any]: """Create a 2D line pipeline — same as ui_pipeline but LINE_LIST topology.""" ffi = vk.ffi # Push constant: vec2 screen_size = 8 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 = 8 layout_ci = ffi.new("VkPipelineLayoutCreateInfo*") layout_ci.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO 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 # 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 pi.stageCount = 2 pi.pStages = stages # Vertex input: pos(vec2) + uv(vec2) + colour(vec4) = 32 bytes binding_desc = ffi.new("VkVertexInputBindingDescription*") binding_desc.binding = 0 binding_desc.stride = VERTEX_STRIDE 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_R32G32_SFLOAT attr_descs[0].offset = 0 attr_descs[1].location = 1 attr_descs[1].binding = 0 attr_descs[1].format = vk.VK_FORMAT_R32G32_SFLOAT attr_descs[1].offset = 8 attr_descs[2].location = 2 attr_descs[2].binding = 0 attr_descs[2].format = vk.VK_FORMAT_R32G32B32A32_SFLOAT attr_descs[2].offset = 16 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 — LINE_LIST ia = ffi.new("VkPipelineInputAssemblyStateCreateInfo*") ia.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO ia.topology = vk.VK_PRIMITIVE_TOPOLOGY_LINE_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 # Alpha blending cba = ffi.new("VkPipelineColorBlendAttachmentState*") cba.blendEnable = 1 cba.srcColorBlendFactor = vk.VK_BLEND_FACTOR_SRC_ALPHA cba.dstColorBlendFactor = vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA cba.colorBlendOp = vk.VK_BLEND_OP_ADD cba.srcAlphaBlendFactor = vk.VK_BLEND_FACTOR_ONE cba.dstAlphaBlendFactor = vk.VK_BLEND_FACTOR_ZERO cba.alphaBlendOp = vk.VK_BLEND_OP_ADD 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 = 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("2D line pipeline created") return pipeline_out[0], pipeline_layout def _create_textured_ui_pipeline( device: Any, vert_module: Any, frag_module: Any, render_pass: Any, extent: tuple[int, int], tex_descriptor_layout: Any, ) -> tuple[Any, Any]: """Create a 2D textured pipeline — same as ui_pipeline but with bindless textures. Push constants: vec2 screen_size (8 bytes) + int texture_id (4 bytes) = 12 bytes. Descriptor set 0: bindless sampler2D array. """ ffi = vk.ffi 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 = 12 # vec2 screen_size + int texture_id layouts = ffi.new("VkDescriptorSetLayout[1]", [tex_descriptor_layout]) layout_ci = ffi.new("VkPipelineLayoutCreateInfo*") layout_ci.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO layout_ci.setLayoutCount = 1 layout_ci.pSetLayouts = 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 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 pi.stageCount = 2 pi.pStages = stages binding_desc = ffi.new("VkVertexInputBindingDescription*") binding_desc.binding = 0 binding_desc.stride = VERTEX_STRIDE 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_R32G32_SFLOAT attr_descs[0].offset = 0 attr_descs[1].location = 1 attr_descs[1].binding = 0 attr_descs[1].format = vk.VK_FORMAT_R32G32_SFLOAT attr_descs[1].offset = 8 attr_descs[2].location = 2 attr_descs[2].binding = 0 attr_descs[2].format = vk.VK_FORMAT_R32G32B32A32_SFLOAT attr_descs[2].offset = 16 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 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 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 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 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 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 cba = ffi.new("VkPipelineColorBlendAttachmentState*") cba.blendEnable = 1 cba.srcColorBlendFactor = vk.VK_BLEND_FACTOR_SRC_ALPHA cba.dstColorBlendFactor = vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA cba.colorBlendOp = vk.VK_BLEND_OP_ADD cba.srcAlphaBlendFactor = vk.VK_BLEND_FACTOR_ONE cba.dstAlphaBlendFactor = vk.VK_BLEND_FACTOR_ZERO cba.alphaBlendOp = vk.VK_BLEND_OP_ADD 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 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("2D textured pipeline created") return pipeline_out[0], pipeline_layout def _create_fill_descriptor_layout(device: Any) -> Any: """Set 0 = single fragment-stage sampler2D (the Light2DPass accumulation RT).""" binding = vk.VkDescriptorSetLayoutBinding( binding=0, descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, descriptorCount=1, stageFlags=vk.VK_SHADER_STAGE_FRAGMENT_BIT, ) return vk.vkCreateDescriptorSetLayout(device, vk.VkDescriptorSetLayoutCreateInfo( bindingCount=1, pBindings=[binding], ), None) def _write_fill_descriptor(device: Any, desc_set: Any, view: Any, sampler: Any) -> None: """Point the fill descriptor set at the given sampler+view (both must be non-null).""" image_info = vk.VkDescriptorImageInfo( sampler=sampler, imageView=view, imageLayout=vk.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, ) vk.vkUpdateDescriptorSets(device, 1, [vk.VkWriteDescriptorSet( dstSet=desc_set, dstBinding=0, dstArrayElement=0, descriptorCount=1, descriptorType=vk.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, pImageInfo=[image_info], )], 0, None) def _build_fill_push( screen_w: float, screen_h: float, ambient: tuple[float, float, float, float], has_lights: int, ) -> bytes: """Pack the draw2d_fill.frag push-constant block (48 bytes). Layout matches Draw2DPush in draw2d_fill.frag: vec2 screen_size (8) + vec2 _pad (8) + vec4 ambient (16) + ivec4 flags (16) """ header = np.array([screen_w, screen_h, 0.0, 0.0, *ambient], dtype=np.float32) flags = np.array([has_lights, 0, 0, 0], dtype=np.int32) return header.tobytes() + flags.tobytes() def _create_fill_pipeline( device: Any, vert_module: Any, frag_module: Any, render_pass: Any, extent: tuple[int, int], desc_layout: Any, ) -> tuple[Any, Any]: """Create the Draw2D fill pipeline — triangle topology, light-accum descriptor set 0. Matches the ui pipeline layout (vec2+vec2+vec4 vertex, alpha blend, no depth) but adds a combined image sampler at set 0 and a 48-byte push constant. """ ffi = vk.ffi # Pipeline layout: set 0 = sampler2D, push = 48B set_layouts = ffi.new("VkDescriptorSetLayout[1]", [desc_layout]) 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 = FILL_PUSH_SIZE 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 # 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 pi.stageCount = 2 pi.pStages = stages # Vertex input: pos(vec2) + uv(vec2) + colour(vec4) = 32 bytes binding_desc = ffi.new("VkVertexInputBindingDescription*") binding_desc.binding = 0 binding_desc.stride = VERTEX_STRIDE 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_R32G32_SFLOAT attr_descs[0].offset = 0 attr_descs[1].location = 1 attr_descs[1].binding = 0 attr_descs[1].format = vk.VK_FORMAT_R32G32_SFLOAT attr_descs[1].offset = 8 attr_descs[2].location = 2 attr_descs[2].binding = 0 attr_descs[2].format = vk.VK_FORMAT_R32G32B32A32_SFLOAT attr_descs[2].offset = 16 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 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 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 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 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 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 # Alpha blending (matches ui_pipeline) cba = ffi.new("VkPipelineColorBlendAttachmentState*") cba.blendEnable = 1 cba.srcColorBlendFactor = vk.VK_BLEND_FACTOR_SRC_ALPHA cba.dstColorBlendFactor = vk.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA cba.colorBlendOp = vk.VK_BLEND_OP_ADD cba.srcAlphaBlendFactor = vk.VK_BLEND_FACTOR_ONE cba.dstAlphaBlendFactor = vk.VK_BLEND_FACTOR_ZERO cba.alphaBlendOp = vk.VK_BLEND_OP_ADD 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 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("Draw2D fill pipeline created (light-accum modulation)") return pipeline_out[0], pipeline_layout