Source code for simvx.graphics.renderer.decal_pack

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