Source code for simvx.graphics.ui.ui_pass

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


from __future__ import annotations

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