Source code for simvx.graphics.renderer.reflection_probe_pack

"""ReflectionProbe3D std430 box-record packing: backend-agnostic, numpy only.

The per-probe box record (``Probe`` in ``cube_textured.frag`` / the ``ProbeBuffer``
SSBO) is shared by the desktop Vulkan ``reflection_probe_pass`` (which uploads it
to a GPU buffer indexed by capture slot) and the web export's
``scene3d_serializer`` (which streams it to the browser WebGPU runtime). Keeping
the packing here, free of any ``vulkan`` import, lets the web export bundle it
into Pyodide without dragging in the desktop graphics stack. Both backends march
the identical 48-byte std430 record so the same scene reflects identically.

Slot ownership differs by backend. Desktop assigns the cube-array slot inside the
capture pass and writes each record at ``off = 16 + slot*48`` with
``centre_slice.w = slot*6``. On web the capture pass (``reflection_probe_pass.js``)
owns slotting, so the wire records are emitted in *probe order*, each prefixed
with a stable ``probe id`` (a u32). The JS runtime resolves ``id -> slot`` against
the live capture pass and patches ``centre_slice.w = slot*6`` before upload, so
the shader still sees the desktop ``centre_slice.w / 6.0`` array layer.
"""

from typing import Any

import numpy as np

__all__ = [
    "MAX_PROBES",
    "PROBE_RECORD_STRIDE",
    "PROBE_WIRE_STRIDE",
    "SCHED_EVERY_FRAME",
    "SCHED_MODE_ALWAYS",
    "SCHED_UPDATE_REQUESTED",
    "build_probe_records",
]

# Mirrors ``buffer_manager.MAX_PROBES`` / ``cube_textured.frag:100``. The GPU box
# buffer is allocated once at this cap; the per-fragment blend loop only iterates
# the live count and only the top-2 probes do texture fetches.
MAX_PROBES = 8

# std430 ``Probe`` record: 3 × vec4 = 48 bytes.
#   vec4 centre_slice          (xyz = box centre world, w = array slice as float)
#   vec4 half_extent_intensity (xyz = box half-extents, w = intensity)
#   vec4 flags                 (x = box_projection 0/1, y = blend_distance, zw reserved)
PROBE_RECORD_STRIDE = 48

# Web wire record: 4-byte ``probe id`` prefix + the 48-byte std430 record. The id
# lets the JS runtime map each record to the capture pass's slot; it is stripped
# (and ``centre_slice.w`` patched) before the bytes reach the GPU SSBO.
PROBE_WIRE_STRIDE = 4 + PROBE_RECORD_STRIDE

# Scheduling bitfield packed into ``flags.z`` (a reserved std430 field the shader
# ignores) so the wire record fully describes the probe to the JS capture pass.
SCHED_MODE_ALWAYS = 1  # bit 0: update_mode == "always" (else "once")
SCHED_EVERY_FRAME = 2  # bit 1: time_slicing == "every_frame" (else "round_robin")
SCHED_UPDATE_REQUESTED = 4  # bit 2: request_update() pending


[docs] def build_probe_records(probes: list[Any]) -> tuple[np.ndarray, int]: """Pack ``ReflectionProbe3D`` nodes into the web wire byte array. Returns ``(bytes_array, count)`` where ``count`` is clamped to ``MAX_PROBES``. Each ``PROBE_WIRE_STRIDE``-byte record is:: u32 probe id (stable per-node id; JS resolves id -> slot) vec4 centre_slice (xyz = capture_position, w = 0.0 placeholder*) vec4 half_extent_intensity (xyz = size, w = intensity) vec4 flags (x = box_projection, y = blend_distance, z = scheduling bitfield, w = 0) \\* ``centre_slice.w`` is a placeholder on the wire; the JS runtime overwrites it with ``slot * 6`` once the capture pass has assigned the cube-array slot, so the shader's ``centre_slice.w / 6.0`` array layer matches the desktop. The xyz/intensity/box_projection/blend_distance fields are byte-identical to the desktop ``_upload_boxes`` pack (``reflection_probe_pass.py``). ``flags.z`` is a reserved std430 field the shader ignores; it carries the capture-pass scheduling bits (``SCHED_*``) so the wire record fully describes the probe. """ n = min(len(probes), MAX_PROBES) buf = np.zeros(max(1, n) * PROBE_WIRE_STRIDE, dtype=np.uint8) u32 = buf.view(np.uint32) f32 = buf.view(np.float32) for i in range(n): probe = probes[i] centre = np.asarray(probe.capture_position, dtype=np.float32) half = np.asarray(probe.size, dtype=np.float32) intensity = float(getattr(probe, "intensity", 1.0)) box_proj = 1.0 if getattr(probe, "box_projection", False) else 0.0 blend = float(getattr(probe, "blend_distance", 1.0)) sched = 0 if str(getattr(probe, "update_mode", "once")) == "always": sched |= SCHED_MODE_ALWAYS if str(getattr(probe, "time_slicing", "round_robin")) == "every_frame": sched |= SCHED_EVERY_FRAME if getattr(probe, "_update_requested", False): sched |= SCHED_UPDATE_REQUESTED word = i * PROBE_WIRE_STRIDE // 4 u32[word] = np.uint32(id(probe) & 0xFFFFFFFF) base = word + 1 # vec4 centre_slice (xyz centre, w = slice placeholder, patched JS-side). f32[base:base + 4] = [float(centre[0]), float(centre[1]), float(centre[2]), 0.0] # vec4 half_extent_intensity (xyz half extents, w = intensity). f32[base + 4:base + 8] = [float(half[0]), float(half[1]), float(half[2]), intensity] # vec4 flags (x = box_projection, y = blend_distance, z = sched bits, w = 0). f32[base + 8:base + 12] = [box_proj, blend, float(sched), 0.0] return buf, n