Source code for simvx.graphics.ui.ui_pass

"""Orthographic 2D pass with dynamic vertex batching for UI elements."""

import logging
import struct
from typing import Any

import numpy as np
import vulkan as vk

from ..gpu.memory import create_buffer, upload_numpy

log = logging.getLogger(__name__)

__all__ = ["UIPass", "TextButton"]

# Vertex format: position (vec2) + uv (vec2) + colour (vec4) = 32 bytes
UI_VERTEX_DTYPE = np.dtype(
    [
        ("position", np.float32, 2),
        ("uv", np.float32, 2),
        ("colour", np.float32, 4),
    ]
)

MAX_UI_VERTICES = 4096

# Block-style glyph patterns: each glyph is a 5x7 grid encoded as list of (x, y, w, h) rects
_GLYPHS = {
    "s": [(1, 0, 4, 1), (0, 1, 1, 1), (1, 2, 3, 1), (4, 3, 1, 2), (0, 5, 4, 1), (4, 5, 1, 1)],
    "i": [(2, 0, 1, 1), (2, 2, 1, 5)],
    "n": [(0, 2, 1, 5), (1, 2, 2, 1), (3, 3, 1, 1), (4, 2, 1, 5)],
    "g": [(1, 2, 3, 1), (0, 3, 1, 2), (4, 3, 1, 2), (1, 5, 3, 1), (4, 5, 1, 1), (1, 6, 4, 1)],
    "l": [(0, 0, 1, 6), (1, 6, 3, 1)],
    "e": [(1, 2, 3, 1), (0, 3, 1, 1), (4, 3, 1, 1), (0, 4, 5, 1), (0, 5, 1, 1), (1, 6, 4, 1)],
    "m": [(0, 2, 1, 5), (1, 2, 1, 1), (2, 3, 1, 4), (3, 2, 1, 1), (4, 2, 1, 5)],
    "u": [(0, 2, 1, 4), (4, 2, 1, 4), (1, 6, 3, 1)],
    "t": [(1, 0, 1, 2), (0, 2, 3, 1), (1, 3, 1, 3), (2, 6, 2, 1)],
    "y": [(0, 2, 1, 2), (4, 2, 1, 2), (1, 4, 3, 1), (3, 5, 1, 1), (1, 6, 2, 1)],
    " ": [],
}

GLYPH_WIDTH = 6  # 5 pixels + 1 spacing
GLYPH_HEIGHT = 8  # 7 pixels + 1 spacing
PIXEL_SCALE = 3  # Scale factor for each glyph "pixel"

[docs] class UIPass: """Renders 2D quads and text using an orthographic projection.""" def __init__(self, device: Any, physical_device: Any, extent: tuple[int, int]) -> None: self.device = device self.physical_device = physical_device self.extent = extent self._pipeline: Any = None self._pipeline_layout: Any = None self._vb_buffer: Any = None self._vb_memory: Any = None self._vertices = np.zeros(MAX_UI_VERTICES, dtype=UI_VERTEX_DTYPE) self._vertex_count = 0 self._owns_buffer = False
[docs] def create(self, pipeline: Any, pipeline_layout: Any, vb: tuple[Any, Any] | None = None) -> None: """Initialize with pre-created pipeline. Optionally pass (buffer, memory) tuple.""" self._pipeline = pipeline self._pipeline_layout = pipeline_layout self._owns_buffer = vb is None if vb: self._vb_buffer, self._vb_memory = vb else: self._vb_buffer, self._vb_memory = create_buffer( self.device, self.physical_device, self._vertices.nbytes, vk.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, vk.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | vk.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, )
[docs] def begin_batch(self) -> None: """Start a new UI draw batch.""" self._vertex_count = 0
[docs] def add_quad( self, x: float, y: float, w: float, h: float, colour: tuple[float, ...] | np.ndarray = (1.0, 1.0, 1.0, 1.0), ) -> None: """Add a coloured quad to the batch (2 triangles = 6 vertices).""" if self._vertex_count + 6 > MAX_UI_VERTICES: return i = self._vertex_count v = self._vertices # Triangle 1: top-left, top-right, bottom-right v[i + 0]["position"] = (x, y) v[i + 1]["position"] = (x + w, y) v[i + 2]["position"] = (x + w, y + h) # Triangle 2: top-left, bottom-right, bottom-left v[i + 3]["position"] = (x, y) v[i + 4]["position"] = (x + w, y + h) v[i + 5]["position"] = (x, y + h) for j in range(6): v[i + j]["uv"] = (0, 0) v[i + j]["colour"] = colour self._vertex_count += 6
[docs] def add_text( self, text: str, x: float, y: float, colour: tuple[float, ...] | np.ndarray = (1.0, 1.0, 1.0, 1.0), ) -> None: """Render text using block-style glyphs.""" cx = x for ch in text.lower(): rects = _GLYPHS.get(ch) if rects is None: cx += GLYPH_WIDTH * PIXEL_SCALE continue for gx, gy, gw, gh in rects: self.add_quad( cx + gx * PIXEL_SCALE, y + gy * PIXEL_SCALE, gw * PIXEL_SCALE, gh * PIXEL_SCALE, colour, ) cx += GLYPH_WIDTH * PIXEL_SCALE
[docs] def flush(self, cmd: Any) -> None: """Upload and draw the current batch.""" if self._vertex_count == 0: return # Upload vertices upload_numpy(self.device, self._vb_memory, self._vertices[: self._vertex_count]) # Bind pipeline vk.vkCmdBindPipeline(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self._pipeline) # Push screen size pc_data = struct.pack("ff", float(self.extent[0]), float(self.extent[1])) ffi = vk.ffi cbuf = ffi.new("char[]", pc_data) vk._vulkan.lib.vkCmdPushConstants( cmd, self._pipeline_layout, vk.VK_SHADER_STAGE_VERTEX_BIT | vk.VK_SHADER_STAGE_FRAGMENT_BIT, 0, len(pc_data), cbuf, ) # Bind vertex buffer and draw vk.vkCmdBindVertexBuffers(cmd, 0, 1, [self._vb_buffer], [0]) vk.vkCmdDraw(cmd, self._vertex_count, 1, 0, 0)
[docs] def resize(self, extent: tuple[int, int]) -> None: self.extent = extent
[docs] def destroy(self) -> None: if not self._owns_buffer: return # Externally managed buffer if self._vb_buffer: vk.vkDestroyBuffer(self.device, self._vb_buffer, None) self._vb_buffer = None if self._vb_memory: vk.vkFreeMemory(self.device, self._vb_memory, None) self._vb_memory = None
[docs] class TextButton: """Simple clickable text region for UI interaction.""" def __init__(self, text: str, x: float, y: float) -> None: self.text = text self.x = x self.y = y self.width = len(text) * GLYPH_WIDTH * PIXEL_SCALE self.height = GLYPH_HEIGHT * PIXEL_SCALE
[docs] def contains(self, mx: float, my: float) -> bool: return self.x <= mx <= self.x + self.width and self.y <= my <= self.y + self.height