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).

Module Contents

Functions

pack_decal

Pack one decal into a :data:DECAL_DTYPE record.

compute_decal_tile_masks

Build the per-screen-tile decal bitmask for the standalone cull grid (RM-G8).

build_decal_buffer

Assemble the full DecalBuffer bytes (16-byte header + record array).

Data

API

simvx.graphics.renderer.decal_pack.__all__

[‘MAX_DECALS’, ‘DECAL_DTYPE’, ‘DECAL_STRIDE’, ‘DECAL_HEADER_SIZE’, ‘DECAL_BUFFER_SIZE’, ‘DECAL_TILE_…

simvx.graphics.renderer.decal_pack.MAX_DECALS

64

simvx.graphics.renderer.decal_pack.DECAL_DTYPE

‘dtype(…)’

simvx.graphics.renderer.decal_pack.DECAL_STRIDE

None

simvx.graphics.renderer.decal_pack.DECAL_HEADER_SIZE

16

simvx.graphics.renderer.decal_pack.DECAL_BUFFER_SIZE

None

simvx.graphics.renderer.decal_pack.DECAL_TILE_PX

16

simvx.graphics.renderer.decal_pack.pack_decal(model: numpy.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 = 4294967295) numpy.ndarray[source]

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.

simvx.graphics.renderer.decal_pack.compute_decal_tile_masks(models: list[numpy.ndarray], view: numpy.ndarray, proj: numpy.ndarray, width: int, height: int, tile_px: int = DECAL_TILE_PX) tuple[int, int, numpy.ndarray][source]

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.

simvx.graphics.renderer.decal_pack.build_decal_buffer(records: list[numpy.ndarray], *, tile_masks: numpy.ndarray | None = None, grid_x: int = 0, grid_y: int = 0, tile_px: int = 0) numpy.ndarray[source]

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.