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