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