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