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