"""Shared decal wire packing (design D9, RM-E5).
Dependency-free (numpy only, no Vulkan) so BOTH the desktop forward pass
(``forward.py`` -> the ``DecalBuffer`` SSBO at set0 binding 19) and, one step
behind, the browser exporter (RM-E5w, WebGPU under Pyodide where ``vulkan`` is
unavailable) pack a :class:`simvx.core.decal.Decal3D` into the identical std430
record. One canonical layout, mirrored by the ``Decal`` struct in
``cube_textured.frag`` (desktop) and, later, ``forward3d.wgsl`` (web).
A decal projects its albedo (and an optional normal map) onto the geometry
inside an oriented box, along the box's local -Y axis. ``world_to_local`` is the
inverse of the box's world TRS (scaled by the half-extents), so a world position
inside the box maps into the cube ``[-1, 1]^3``; the uber loop reads the thin
G-buffer world position (design A9/D3), tests the box, and composites the decal
over the material albedo (design D9). A scene with no ``Decal3D`` keeps
``decal_count`` at 0 and the loop never runs, so the frame is byte-identical
(zero cost when unused, the epic backbone).
"""
from __future__ import annotations
from typing import Any
import numpy as np
__all__ = [
"MAX_DECALS",
"DECAL_DTYPE",
"DECAL_STRIDE",
"DECAL_HEADER_SIZE",
"DECAL_BUFFER_SIZE",
"DECAL_TILE_PX",
"pack_decal",
"build_decal_buffer",
"compute_decal_tile_masks",
]
# Fixed small cap: bounded VRAM + a bounded per-fragment loop in the uber shader.
# A standalone bounded global list (design D9 pre-authorized pivot: no forward+
# tile cull this wave); 64 projectors is ample for a splat/logo/crack showcase.
MAX_DECALS = 64
# Per-decal std430 record, mirrored by the ``Decal`` struct in the shaders.
# mat4 (64) + 3 vec4 (48) = 112 bytes, 16-byte aligned.
DECAL_DTYPE = np.dtype(
[
("world_to_local", np.float32, (16,)), # inverse box TRS: world -> [-1,1]^3
("modulate_mix", np.float32, (4,)), # rgb = tint, a = albedo_mix
("tex_params", np.float32, (4,)), # x=albedo_tex, y=normal_tex, z=normal_mix, w=cull_mask(bits)
("fades", np.float32, (4,)), # x=upper_fade, y=lower_fade, z=dist_begin, w=dist_length
]
)
DECAL_STRIDE = DECAL_DTYPE.itemsize # 112
# Buffer header: ``uint decal_count`` + three tiling words = 16 bytes, then the
# record array (std430 first array element sits at 16, matching the mat4-aligned
# stride). The three words after the count carry the screen-tile grid the desktop
# uber uses to bound its per-fragment decal loop (RM-G8): ``grid_x`` / ``grid_y``
# tiles and the ``tile_px`` size. A grid_x of 0 selects the brute-force fallback
# (every decal tested), which is what the web twin (still one step behind) writes,
# so an untiled buffer is byte-identical to the pre-G8 wire.
DECAL_HEADER_SIZE = 16
DECAL_BUFFER_SIZE = DECAL_HEADER_SIZE + MAX_DECALS * DECAL_STRIDE
# Screen-tile size in pixels for the standalone decal cull grid (RM-G8). Matches
# the forward+ light tile granularity so ``gl_FragCoord`` tile maths are uniform.
DECAL_TILE_PX = 16
[docs]
def pack_decal(
model: np.ndarray,
modulate: Any,
albedo_mix: float,
albedo_tex: int,
normal_tex: int,
normal_mix: float,
upper_fade: float,
lower_fade: float,
dist_begin: float,
dist_length: float,
cull_mask: int = 0xFFFFFFFF,
) -> np.ndarray:
"""Pack one decal into a :data:`DECAL_DTYPE` record.
``model`` is the box world TRS (row-major, translate * rotate * scale where
scale is the world-space half-extents); it is inverted and transposed to the
column-major ``world_to_local`` the shader multiplies the world position by.
``albedo_tex`` / ``normal_tex`` are bindless texture indices (-1 = none).
``cull_mask`` is the 32-bit render-layer bitmask (design D9/RM-G8): a fragment
only receives the decal when its instance ``render_layer`` intersects this
mask. It is stored bit-exact in ``tex_params.w`` (read back with
``floatBitsToUint``); the default ``0xFFFFFFFF`` matches every layer, so an
unmasked decal is byte-identical to the pre-G8 record.
"""
rec = np.zeros((), dtype=DECAL_DTYPE)
inv = np.linalg.inv(model.astype(np.float32))
rec["world_to_local"] = np.ascontiguousarray(inv.T, dtype=np.float32).ravel() # column-major for the shader
rec["modulate_mix"] = (float(modulate[0]), float(modulate[1]), float(modulate[2]), float(albedo_mix))
rec["tex_params"] = (float(int(albedo_tex)), float(int(normal_tex)), float(normal_mix), 0.0)
# Store the cull mask bit-exact (never used as a float; the shader reads it
# with floatBitsToUint). A pure storage round-trip, so the bit pattern of
# 0xFFFFFFFF (a NaN as a float) survives untouched.
rec["tex_params"][3] = np.uint32(int(cull_mask) & 0xFFFFFFFF).view(np.float32)
rec["fades"] = (float(upper_fade), float(lower_fade), float(dist_begin), float(dist_length))
return rec
[docs]
def compute_decal_tile_masks(
models: list[np.ndarray],
view: np.ndarray,
proj: np.ndarray,
width: int,
height: int,
tile_px: int = DECAL_TILE_PX,
) -> tuple[int, int, np.ndarray]:
"""Build the per-screen-tile decal bitmask for the standalone cull grid (RM-G8).
Each of the (up to :data:`MAX_DECALS`) ``models`` is the box world TRS whose 8
corners are projected with ``proj @ view`` into the ``width`` x ``height``
``gl_FragCoord`` space; the conservative screen AABB (padded one tile on every
side, and widened to the whole grid for a projector that crosses the near
plane) sets the decal's bit in every tile it can touch. The mask is a 64-bit
field per tile (two ``uint32`` lanes), so the shader iterates only the decals
whose bit is set for its tile. Because the coverage is always a superset of the
real box footprint, the tiled loop composites the exact same decals a
brute-force loop would: byte-identical output, just fewer per-fragment tests.
Returns ``(grid_x, grid_y, masks)`` where ``masks`` is a ``(grid_x*grid_y, 2)``
``uint32`` array in row-major tile order (``tile = ty * grid_x + tx``). Pure
numpy so the web twin (RM-G8w) can mirror it verbatim in JS.
"""
tile_px = max(1, int(tile_px))
grid_x = (max(1, int(width)) + tile_px - 1) // tile_px
grid_y = (max(1, int(height)) + tile_px - 1) // tile_px
masks = np.zeros((grid_y * grid_x, 2), dtype=np.uint32)
if not models:
return grid_x, grid_y, masks
vp = (proj.astype(np.float32) @ view.astype(np.float32)).astype(np.float32)
# Unit-cube corners as column vectors; ``model @ corner`` gives world space.
corners = np.array(
[(sx, sy, sz, 1.0) for sx in (-1.0, 1.0) for sy in (-1.0, 1.0) for sz in (-1.0, 1.0)],
dtype=np.float32,
).T # (4, 8)
masks3 = masks.reshape(grid_y, grid_x, 2)
for i, model in enumerate(models[:MAX_DECALS]):
clip = (vp @ (model.astype(np.float32) @ corners)).T # (8, 4)
w = clip[:, 3]
if np.any(w <= 1e-4):
tx0, ty0, tx1, ty1 = 0, 0, grid_x - 1, grid_y - 1 # near-plane crossing: whole grid
else:
ndc = clip[:, :3] / w[:, None]
px = (ndc[:, 0] * 0.5 + 0.5) * width
py = (ndc[:, 1] * 0.5 + 0.5) * height
tx0 = max(0, int(np.floor(px.min() / tile_px)) - 1)
ty0 = max(0, int(np.floor(py.min() / tile_px)) - 1)
tx1 = min(grid_x - 1, int(np.floor(px.max() / tile_px)) + 1)
ty1 = min(grid_y - 1, int(np.floor(py.max() / tile_px)) + 1)
if tx1 < tx0 or ty1 < ty0:
continue # fully off-screen: no tile can contain this box
bit = np.uint32(1) << np.uint32(i % 32)
masks3[ty0 : ty1 + 1, tx0 : tx1 + 1, i // 32] |= bit
return grid_x, grid_y, masks
[docs]
def build_decal_buffer(
records: list[np.ndarray],
*,
tile_masks: np.ndarray | None = None,
grid_x: int = 0,
grid_y: int = 0,
tile_px: int = 0,
) -> np.ndarray:
"""Assemble the full ``DecalBuffer`` bytes (16-byte header + record array).
The record region is always the fixed :data:`MAX_DECALS` slots (the shader
declares ``Decal decals[MAX_DECALS]`` so the trailing tile-mask array has a
fixed offset); only the first ``decal_count`` are populated. A zeroed header
(no records) is byte-identical to the never-baked default buffer.
When ``tile_masks`` is supplied (desktop screen-tile cull, RM-G8) the
``(grid_x, grid_y, tile_px)`` header words are set and the ``uint32`` mask
lanes are appended after the record region as the shader's ``tile_mask[]``
runtime array. Called without it (the web twin, one step behind) the header
tiling words stay 0 and the buffer is exactly the pre-G8 wire, so the
brute-force shader path runs and nothing changes.
"""
n = min(len(records), MAX_DECALS)
tail = b""
if tile_masks is not None and grid_x > 0 and grid_y > 0:
tail = np.ascontiguousarray(tile_masks, dtype=np.uint32).tobytes()
buf = np.zeros(DECAL_BUFFER_SIZE + len(tail), dtype=np.uint8)
header = np.array([n, int(grid_x), int(grid_y), int(tile_px)], dtype=np.uint32)
buf[0:DECAL_HEADER_SIZE] = header.view(np.uint8)
if n:
arr = np.zeros(n, dtype=DECAL_DTYPE)
for i in range(n):
arr[i] = records[i]
buf[DECAL_HEADER_SIZE : DECAL_HEADER_SIZE + n * DECAL_STRIDE] = arr.view(np.uint8)
if tail:
buf[DECAL_BUFFER_SIZE:] = np.frombuffer(tail, dtype=np.uint8)
return buf