"""Text utility helpers extracted from Engine: font finding and text rasterization."""
from typing import TYPE_CHECKING, Any
import vulkan as vk
from simvx.core.text import rasterize_text
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]
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()
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
[docs]
@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
[docs]
@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,
)