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