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 dataclasses import dataclass
from typing import Any

import numpy as np
import vulkan as vk

from ..types import SHADER_DIR
from ..draw2d_vertex import UI_VERTEX_DTYPE
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", "last_frame_draw_count", ) 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 self.last_frame_draw_count = 0
[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, ops: list | None = None) -> None: """Render 2D geometry in submission order. Walks ``Draw2D._ops`` (or the provided ``ops`` list, e.g. an isolated play-mode tree), coalesces adjacent same-(kind, clip, tex_id) ops into one GPU draw, and emits draws in submission order, so the order in which ``draw_rect`` / ``draw_text`` / ``draw_texture`` calls happen in ``on_draw`` is the order they hit the framebuffer. Args: ops: Pre-extracted op list. If None, pulls from Draw2D singleton. """ if not self._ready: self.last_frame_draw_count = 0 return from ..draw2d import Draw2D from ..draw2d_ops import OpKind if ops is None: ops = Draw2D._ops if not ops: self.last_frame_draw_count = 0 return 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), ) # Pass 1: coalesce ops into runs by (kind, clip, tex_id). Each run is # one GPU draw. Accumulate raw vertex tuples + integer indices into # per-kind staging lists; one numpy conversion happens per kind at # upload time (Pass 2): not per op. kind_verts: dict[int, list[tuple]] = {k: [] for k in (0, 1, 2, 3)} kind_indices: dict[int, list[int]] = {k: [] for k in (0, 1, 2, 3)} kind_vert_cursor = [0, 0, 0, 0] kind_idx_cursor = [0, 0, 0, 0] # (kind, clip, tex_id, vert_start, vert_count, idx_start, idx_count) draws: list[tuple[int, tuple | None, int, int, int, int, int]] = [] sentinel = object() run_kind: int | None = None run_clip: Any = sentinel run_tex = -1 run_vert_start = 0 run_idx_start = 0 run_vert_count = 0 run_idx_count = 0 run_local_vert_off = 0 for op in ops: k = int(op.kind) same_run = ( k == run_kind and op.clip == run_clip and (k != OpKind.TEX or op.tex_id == run_tex) ) if not same_run: if run_kind is not None and (run_vert_count or run_idx_count): draws.append(( run_kind, run_clip, run_tex, run_vert_start, run_vert_count, run_idx_start, run_idx_count, )) run_kind = k run_clip = op.clip run_tex = op.tex_id run_vert_start = kind_vert_cursor[k] run_idx_start = kind_idx_cursor[k] run_vert_count = 0 run_idx_count = 0 run_local_vert_off = 0 nv = len(op.verts) kind_verts[k].extend(op.verts) kind_vert_cursor[k] += nv run_vert_count += nv if op.indices is not None: # Indices in the per-op payload are local to the op (start at 0). # Within a run we offset them by the run-local vertex count so the # concatenated index stream is valid relative to ``run_vert_start``, # which becomes vkCmdDrawIndexed's vertexOffset. if run_local_vert_off: kind_indices[k].extend(i + run_local_vert_off for i in op.indices) else: kind_indices[k].extend(op.indices) kind_idx_cursor[k] += len(op.indices) run_idx_count += len(op.indices) run_local_vert_off += nv if run_kind is not None and (run_vert_count or run_idx_count): draws.append(( run_kind, run_clip, run_tex, run_vert_start, run_vert_count, run_idx_start, run_idx_count, )) if not draws: self.last_frame_draw_count = 0 return # Pass 2: upload each kind's concatenated buffer once (clamped to capacity). # The numpy conversion happens here, not per-op. def _upload(k: int, vb_mem: Any, ib_mem: Any | None, max_v: int, max_i: int | None) -> None: verts = kind_verts[k] if not verts: return arr = np.asarray(verts, dtype=np.float32).reshape(-1, 8) if arr.shape[0] > max_v: log.warning("Draw2D overflow (kind %d): %d verts (max %d)", k, arr.shape[0], max_v) arr = arr[:max_v] v = np.empty(arr.shape[0], dtype=UI_VERTEX_DTYPE) v["position"] = arr[:, :2] v["uv"] = arr[:, 2:4] v["colour"] = arr[:, 4:8] upload_numpy(device, vb_mem, v) if ib_mem is not None and kind_indices[k]: i = np.asarray(kind_indices[k], dtype=np.uint32) if max_i is not None and len(i) > max_i: i = i[:max_i] upload_numpy(device, ib_mem, i) _upload(OpKind.FILL, self._fill_vb_mem, self._fill_ib_mem, MAX_FILL_VERTS, MAX_FILL_INDICES) _upload(OpKind.LINE, self._line_vb_mem, None, MAX_LINE_VERTS, None) _upload(OpKind.TEXT, self._text_vb_mem, self._text_ib_mem, MAX_TEXT_VERTS, MAX_TEXT_INDICES) _upload(OpKind.TEX, self._tex_vb_mem, self._tex_ib_mem, MAX_TEX_VERTS, MAX_TEX_INDICES) # Pass 3: issue draws in submission order. Rebind pipeline only when # the kind transitions; reset scissor only when the clip transitions; # update tex_id push-constant only when the bound texture changes. clip_sx = width / uw if uw > 0 else 1.0 clip_sy = height / uh if uh > 0 else 1.0 last_kind = -1 last_clip: Any = sentinel tex_desc = self._engine.texture_descriptor_set if self._tex_pipeline else None draw_count = 0 for kind, clip, tex_id, vert_off, vert_count, idx_off, idx_count in draws: # Skip text runs when no atlas is ready (mirrors prior behaviour) if kind == OpKind.TEXT and not (self._text_pass and self._text_pass.atlas_version > 0): continue if kind == OpKind.TEX and not tex_desc: continue # Pipeline bind on kind transition if kind != last_kind: if kind == OpKind.FILL: vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._fill_pipeline) 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) elif kind == OpKind.LINE: vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._line_pipeline) self._engine.push_constants(cmd, self._line_pipeline_layout, screen.tobytes()) vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._line_vb], [0]) elif kind == OpKind.TEXT: tp = self._text_pass vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, tp.pipeline) 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) elif kind == OpKind.TEX: vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._tex_pipeline) 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) # Viewport must be set after every pipeline bind (dynamic state) vk.vkCmdSetViewport(cmd, 0, 1, [vk_viewport]) last_clip = sentinel # force scissor refresh after pipeline bind last_kind = kind # Scissor on clip transition if clip != last_clip: 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 vk.vkCmdSetScissor(cmd, 0, 1, [scissor]) last_clip = clip # Per-texture push constants for TEX (tex_id changes within a TEX # pipeline binding because the run boundary already accounts for # tex_id transitions: each TEX draw has one tex_id). if kind == OpKind.TEX: tex_pc = np.array([uw, uh], dtype=np.float32).tobytes() \ + np.array([tex_id], dtype=np.int32).tobytes() self._engine.push_constants(cmd, self._tex_pipeline_layout, tex_pc) # Issue draw if kind == OpKind.LINE: vk.vkCmdDraw(cmd, vert_count, 1, vert_off, 0) else: vk.vkCmdDrawIndexed(cmd, idx_count, 1, idx_off, vert_off, 0) draw_count += 1 self.last_frame_draw_count = draw_count
# Draw2D._reset() is called at start of frame in app.py, before tree.render()
[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
@dataclass(frozen=True) class _Draw2DPipelineSpec: """Differences between the four Draw2D pipelines. Everything not listed here is shared across all four pipelines: vertex input (UI_VERTEX_DTYPE), alpha blending (src-alpha, one-minus-src-alpha on colour and src-alpha-respecting on the alpha channel), no depth, no culling, dynamic viewport + scissor, single colour attachment. Changing any of those defaults is a one-line edit to ``_build_draw2d_ pipeline``, not a four-site search-and-replace. """ name: str topology: int # VK_PRIMITIVE_TOPOLOGY_* push_size: int # bytes; 0 = no push constant range set_layouts: tuple = () # VkDescriptorSetLayout handles def _build_draw2d_pipeline( device: Any, vert_module: Any, frag_module: Any, render_pass: Any, extent: tuple[int, int], spec: _Draw2DPipelineSpec, ) -> tuple[Any, Any]: """Create a Draw2D pipeline from a *spec*. Centralises the ~250 LOC of identical VkGraphicsPipelineCreateInfo plumbing that used to live in three near-identical helpers. The only things that vary across the four Draw2D pipelines are encoded in the spec: see ``_Draw2DPipelineSpec`` for the list. """ ffi = vk.ffi # ---- Pipeline layout ------------------------------------------------ layout_ci = ffi.new("VkPipelineLayoutCreateInfo*") layout_ci.sType = vk.VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO set_layouts_arr = None if spec.set_layouts: set_layouts_arr = ffi.new(f"VkDescriptorSetLayout[{len(spec.set_layouts)}]", list(spec.set_layouts)) layout_ci.setLayoutCount = len(spec.set_layouts) layout_ci.pSetLayouts = set_layouts_arr push_range = None if spec.push_size > 0: 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 = spec.push_size 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({spec.name}) failed: {result}") pipeline_layout = layout_out[0] # ---- Pipeline create info ------------------------------------------ pi = ffi.new("VkGraphicsPipelineCreateInfo*") pi.sType = vk.VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO # Shader stages (vert + frag, both entry point "main") 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]") for i, (loc, fmt, off) in enumerate(( (0, vk.VK_FORMAT_R32G32_SFLOAT, 0), (1, vk.VK_FORMAT_R32G32_SFLOAT, 8), (2, vk.VK_FORMAT_R32G32B32A32_SFLOAT, 16), )): attr_descs[i].location = loc attr_descs[i].binding = 0 attr_descs[i].format = fmt attr_descs[i].offset = off 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 = spec.topology pi.pInputAssemblyState = ia # Viewport (dynamic, but a dummy is still required at create time) 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 # Rasterisation: fill, no cull, lineWidth fixed at 1 (thicker lines # ride the FILL pipeline via Draw2D._emit_rect_outline / draw_line). 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 blend (src-alpha, one-minus-src-alpha for colour; # one × dst, one-minus-src-alpha for alpha: preserves framebuffer alpha) 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_ONE_MINUS_SRC_ALPHA 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: viewport and scissor are set per-draw 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({spec.name}) failed: {result}") log.debug("Draw2D %s pipeline created", spec.name) return pipeline_out[0], pipeline_layout def _create_line2d_pipeline(device, vert_module, frag_module, render_pass, extent): """LINE_LIST, no descriptor set, 8-byte push (vec2 screen_size).""" return _build_draw2d_pipeline( device, vert_module, frag_module, render_pass, extent, _Draw2DPipelineSpec( name="line", topology=vk.VK_PRIMITIVE_TOPOLOGY_LINE_LIST, push_size=8, ), ) def _create_textured_ui_pipeline( device, vert_module, frag_module, render_pass, extent, tex_descriptor_layout, ): """TRIANGLE_LIST, bindless sampler2D[] at set 0, 12-byte push (vec2+int).""" return _build_draw2d_pipeline( device, vert_module, frag_module, render_pass, extent, _Draw2DPipelineSpec( name="textured", topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, push_size=12, # vec2 screen_size + int texture_id set_layouts=(tex_descriptor_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, vert_module, frag_module, render_pass, extent, desc_layout): """TRIANGLE_LIST, light-accum sampler at set 0, 48-byte push (Draw2DPush).""" return _build_draw2d_pipeline( device, vert_module, frag_module, render_pass, extent, _Draw2DPipelineSpec( name="fill", topology=vk.VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, push_size=FILL_PUSH_SIZE, set_layouts=(desc_layout,), ), )