Source code for simvx.graphics.text_utils

"""Text utility helpers extracted from Engine — font finding and text rasterization."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import numpy as np
import vulkan as vk

if TYPE_CHECKING:
    from .gpu.context import GPUContext

__all__ = ["TextTexture", "create_text_texture", "find_font_path", "rasterize_text"]


[docs] def find_font_path() -> str: """Find a usable system font path via the unified font finder.""" from .text_renderer import _find_font path = _find_font() if path is None: raise FileNotFoundError("No system font found. Pass font= explicitly.") return path
[docs] def rasterize_text( text: str, atlas: Any, font_size: int, width: int, height: int, colour: tuple, ) -> np.ndarray: """Rasterize text from an MSDF atlas into an RGBA uint8 image. Samples the MSDF atlas per-pixel, applies median thresholding, and composites coloured glyphs onto a transparent background. """ pixels = np.zeros((height, width, 4), dtype=np.uint8) font = atlas.font scale = font_size / font.size r8 = int(min(255, max(0, colour[0] * 255))) g8 = int(min(255, max(0, colour[1] * 255))) b8 = int(min(255, max(0, colour[2] * 255))) cursor_x = 2.0 # small left margin baseline_y = font.ascender * scale + 2.0 for ch in text: if ch not in atlas.regions: gm = font.get_glyph(ch) cursor_x += gm.advance_x * scale continue region = atlas.regions[ch] gm = region.metrics # Glyph quad position in output image qx = cursor_x + gm.bearing_x * scale qy = baseline_y - gm.bearing_y * scale qw = region.w * scale qh = region.h * scale # Sample atlas region for each output pixel for py in range(max(0, int(qy)), min(height, int(qy + qh))): # V coordinate in atlas v = region.v0 + (py - qy) / qh * (region.v1 - region.v0) av = int(v * atlas.atlas_size) if av < 0 or av >= atlas.atlas_size: continue for px in range(max(0, int(qx)), min(width, int(qx + qw))): # U coordinate in atlas u = region.u0 + (px - qx) / qw * (region.u1 - region.u0) au = int(u * atlas.atlas_size) if au < 0 or au >= atlas.atlas_size: continue # Sample MSDF: median of RGB sr = float(atlas.atlas[av, au, 0]) / 255.0 sg = float(atlas.atlas[av, au, 1]) / 255.0 sb = float(atlas.atlas[av, au, 2]) / 255.0 median = max(min(sr, sg), min(max(sr, sg), sb)) # Smoothstep around 0.5 threshold edge = 0.5 smooth = 0.1 lo, hi = edge - smooth, edge + smooth if median <= lo: alpha = 0.0 elif median >= hi: alpha = 1.0 else: t = (median - lo) / (hi - lo) alpha = t * t * (3.0 - 2.0 * t) if alpha > 0.01: a8 = int(alpha * 255) pixels[py, px] = [r8, g8, b8, a8] cursor_x += gm.advance_x * scale return np.ascontiguousarray(np.flipud(pixels))
[docs] class TextTexture: """GPU-backed text texture for use on 3D objects. Properties ``.text`` and ``.colour`` trigger re-rasterization and GPU upload when changed. """ def __init__( self, ctx: GPUContext, register_texture: Any, texture_descriptor_set: Any, default_sampler: Any, font: str | None = None, size: int = 32, width: int = 256, height: int = 64, ) -> None: from .text_renderer import TextRenderer self._ctx = ctx self._register_texture = register_texture self._texture_descriptor_set = texture_descriptor_set self._default_sampler = default_sampler self._tr = TextRenderer(max_chars=256) self._font = font self._size = size self._width = width self._height = height self._text = "" self._colour = (1.0, 1.0, 1.0, 1.0) self._image = None self._image_mem = None self._image_view = None self.texture_index = -1 @property def text(self) -> str: return self._text @text.setter def text(self, value: str) -> None: if value != self._text: self._text = value self._render() @property def colour(self) -> tuple: return self._colour @colour.setter def colour(self, value: tuple) -> None: if value != self._colour: self._colour = value if self._text: self._render() def _render(self) -> None: """Rasterize text from MSDF atlas into RGBA pixels, upload to GPU.""" from .gpu.descriptors import write_texture_descriptor from .gpu.memory import upload_image_data atlas = self._tr.get_atlas( self._font or find_font_path(), font_size=self._size, ) pixels = rasterize_text( self._text, atlas, self._size, self._width, self._height, self._colour, ) device = self._ctx.device # Upload as GPU texture image, mem = upload_image_data( device, self._ctx.physical_device, self._ctx.graphics_queue, self._ctx.command_pool, pixels, self._width, self._height, ) # Create image view view_ci = vk.VkImageViewCreateInfo( image=image, viewType=vk.VK_IMAGE_VIEW_TYPE_2D, format=vk.VK_FORMAT_R8G8B8A8_UNORM, subresourceRange=vk.VkImageSubresourceRange( aspectMask=vk.VK_IMAGE_ASPECT_COLOR_BIT, baseMipLevel=0, levelCount=1, baseArrayLayer=0, layerCount=1, ), ) new_view = vk.vkCreateImageView(device, view_ci, None) # Clean up previous upload — wait for GPU since old view may be in-flight if self._image_view: vk.vkDeviceWaitIdle(device) vk.vkDestroyImageView(device, self._image_view, None) if self._image: vk.vkDestroyImage(device, self._image, None) if self._image_mem: vk.vkFreeMemory(device, self._image_mem, None) self._image = image self._image_mem = mem self._image_view = new_view # Register or re-register in bindless array if self.texture_index < 0: self.texture_index = self._register_texture(new_view) else: # Update existing descriptor slot write_texture_descriptor( device, self._texture_descriptor_set, self.texture_index, new_view, self._default_sampler, )
[docs] def create_text_texture( ctx: GPUContext, register_texture: Any, texture_descriptor_set: Any, default_sampler: Any, font: str | None = None, size: int = 32, width: int = 256, height: int = 64, ) -> TextTexture: """Create a TextTexture backed by GPU resources from the given context.""" return TextTexture( ctx, register_texture, texture_descriptor_set, default_sampler, font=font, size=size, width=width, height=height, )