Source code for simvx.graphics.renderer.billboard2d_data

"""Billboard2D data layout + row builders (vulkan-free, shared desktop + web).

The ``BILLBOARD_DTYPE`` SSBO row layout and the pure NumPy row builders for the
depth-tested 2D-in-3D billboards (Sprite3D images + Text3D MSDF glyphs, design
§5.2). Kept free of any ``vulkan`` import so the web export can bundle it: the
GPU pass (:mod:`.billboard2d_pass`) re-exports these for the desktop Vulkan path,
and :class:`simvx.web.renderer.web.WebRenderer` builds the identical rows for the
WebGPU backend, so a scene billboards byte-identically on both backends.
"""

from typing import Any

import numpy as np

__all__ = [
    "BILLBOARD_DTYPE",
    "FLAG_IS_MSDF",
    "build_sprite3d_row",
    "build_text3d_rows",
]

# Matches the ``Billboard`` struct in billboard2d.vert / billboard2d.wgsl
# (std430, 80 bytes).
BILLBOARD_DTYPE = np.dtype(
    {
        "names": ["anchor", "tex_id", "offset", "half_size", "uv", "colour", "flags", "_pad"],
        "formats": [
            (np.float32, 3),  # anchor (vec3)
            np.float32,       # tex_id (float; -1 = untextured)
            (np.float32, 2),  # offset (vec2)
            (np.float32, 2),  # half_size (vec2)
            (np.float32, 4),  # uv (u0, v0, u1, v1)
            (np.float32, 4),  # colour (rgba)
            np.uint32,        # flags (bit0 = is_msdf)
            (np.uint32, 3),   # padding to 80 bytes
        ],
        "offsets": [0, 12, 16, 24, 32, 48, 64, 68],
        "itemsize": 80,
    }
)

# is_msdf flag bit (matches billboard2d.frag / billboard2d.wgsl FLAG_IS_MSDF).
FLAG_IS_MSDF = 1


[docs] def build_sprite3d_row( *, texture_id: int, position: Any, width: float, height: float, uv: tuple[float, float, float, float], colour: Any = (1.0, 1.0, 1.0, 1.0), centered: bool = True, offset: tuple[float, float] = (0.0, 0.0), ) -> np.ndarray | None: """Build one ``BILLBOARD_DTYPE`` Sprite3D row (shared desktop + web). The quad is anchored at ``position`` and sized ``width`` x ``height`` in world units on the camera-facing plane; ``centered`` keeps it on the anchor, otherwise its top-left corner sits on the anchor. Returns ``None`` when the texture is unresolved so callers can skip. The camera basis is applied by the per-backend billboard shader. """ if texture_id < 0: return None half_w, half_h = width * 0.5, height * 0.5 ox, oy = float(offset[0]), float(offset[1]) if not centered: ox += half_w oy += half_h row = np.zeros(1, dtype=BILLBOARD_DTYPE) row["anchor"] = (float(position.x), float(position.y), float(position.z)) row["tex_id"] = float(texture_id) row["offset"] = (ox, oy) row["half_size"] = (half_w, half_h) row["uv"] = uv row["colour"] = tuple(float(c) for c in colour) row["flags"] = 0 return row
[docs] def build_text3d_rows( *, text: str, position: Any, resolve_atlas_slot: Any, font_scale: float = 1.0, pixel_size: float = 0.01, colour: Any = (1.0, 1.0, 1.0, 1.0), alignment: str = "centre", offset: tuple[float, float] = (0.0, 0.0), ) -> np.ndarray | None: """Build the ``BILLBOARD_DTYPE`` MSDF glyph rows for a Text3D run (shared). Reuses the unified glyph layout (:func:`layout_glyph_run`) so kerning / size match every other text node, maps the pixel-space glyph quads into world units (``pixel_size``) on the camera-facing plane centred about the anchor, and emits one ``is_msdf`` row per glyph. Returns ``None`` for empty text / unlaid runs / an unresolved atlas. ``resolve_atlas_slot`` is a zero-arg callable returning the backend's MSDF atlas texture slot. It is invoked AFTER ``layout_glyph_run`` so this run's glyphs are already in the shared atlas before the backend (re)uploads it, keeping first-frame text correct on both backends. """ from ..draw2d_text import layout_glyph_run if not text: return None verts, _indices = layout_glyph_run( text, pos=(0.0, 0.0), colour=tuple(float(c) for c in colour), scale=float(font_scale), alignment=str(alignment), ) if not verts: return None atlas_slot = resolve_atlas_slot() if atlas_slot < 0: return None v = np.asarray(verts, dtype=np.float32) # (N, 8): px, py, u, v, r, g, b, a px, py = v[:, 0], v[:, 1] # Centre the run about the anchor (label hovers ON the position). Pixel-space # +y is down; flip into the camera_up (+y up) basis. cx = 0.5 * (float(px.min()) + float(px.max())) cy = 0.5 * (float(py.min()) + float(py.max())) offx = (px - cx) * pixel_size + float(offset[0]) * pixel_size offy = -(py - cy) * pixel_size - float(offset[1]) * pixel_size n = len(verts) // 4 anchor = (float(position.x), float(position.y), float(position.z)) rows = np.zeros(n, dtype=BILLBOARD_DTYPE) for q in range(n): b = q * 4 # layout_glyph_run corner order: TL, TR, BR, BL qx0, qy0 = offx[b], offy[b] qx2, qy2 = offx[b + 2], offy[b + 2] rows[q]["anchor"] = anchor rows[q]["tex_id"] = float(atlas_slot) rows[q]["offset"] = (0.5 * (qx0 + qx2), 0.5 * (qy0 + qy2)) rows[q]["half_size"] = (0.5 * abs(qx2 - qx0), 0.5 * abs(qy2 - qy0)) # uv: corner sign maps x<0->u0, y<0->v0 in the vert; +y is up but v0 is # the glyph TOP, so put v1 on the (-y) bottom corner -> flip v vs offset. rows[q]["uv"] = (v[b, 2], v[b + 2, 3], v[b + 2, 2], v[b, 3]) rows[q]["colour"] = tuple(v[b, 4:8]) rows[q]["flags"] = FLAG_IS_MSDF return rows