"""IrradianceVolume3D: baked diffuse global-illumination probe grid (design D10).
A box-shaped grid of irradiance probes. Each probe stores the low-frequency
diffuse lighting reaching its position as spherical-harmonic L1 coefficients
(12 floats). At shade time a fragment inside the volume trilinearly blends the
eight surrounding probes and evaluates their SH for the surface normal, so a
dynamic object moving through the volume picks up soft, direction-dependent
indirect light baked from the surrounding (e.g. coloured) walls.
The volume is axis-aligned and centred on the node's ``world_position``; the
node's rotation is intentionally ignored for the grid (standard for irradiance
volumes: the probes sample the world, not the node's local frame).
Example::
vol = IrradianceVolume3D(extents=(6, 4, 6), spacing=2.0)
room.add_child(vol)
Capture reuses the reflection-probe cubemap machinery (a small radiance cube per
probe) reduced to SH by a compute pass; ``update_budget`` probes are baked per
frame so a large grid amortises over several frames. ``bake_mode`` "once"
bakes the grid a single time (the common case for static level lighting);
"always" re-bakes round-robin for dynamic scenes; "disabled" leaves the volume
inert (feature-off, byte-identical to a scene with no volume).
"""
import logging
import numpy as np
from .descriptors import Property
from .math.types import Vec3
from .nodes_3d.node3d import Node3D
log = logging.getLogger(__name__)
# Hard cap on total probes in one volume (matches the desktop SH SSBO capacity,
# ``buffer_manager.IRRADIANCE_VOLUME_MAX_PROBES``). A grid that would exceed this
# is coarsened (effective spacing raised) rather than truncated, so the volume
# still spans its full extents.
MAX_PROBES = 256
__all__ = ["IrradianceVolume3D", "MAX_PROBES"]
[docs]
class IrradianceVolume3D(Node3D):
"""A baked diffuse-GI probe grid (SH-L1 per probe), design D10."""
# --- Volume ---
extents = Property((5.0, 5.0, 5.0), hint="Half-extents of the probe box (Vec3, world units)", group="Volume")
spacing = Property(2.5, range=(0.1, 100.0), hint="Target probe spacing (world units)", group="Volume")
intensity = Property(1.0, range=(0.0, 5.0), hint="Indirect-light intensity multiplier", group="Volume")
# --- Capture ---
# Named ``bake_mode`` (not ``update_mode``) because Node already owns an
# ``update_mode`` property (pause/process behaviour); this is the GI bake
# scheduling, a distinct concept.
bake_mode = Property("once", enum=["once", "always", "disabled"], hint="GI bake scheduling", group="Capture")
update_budget = Property(4, range=(1, 64), hint="Probes baked per frame", group="Capture")
def __init__(self, extents=None, spacing=None, **kwargs):
super().__init__(**kwargs)
if extents is not None:
self.extents = tuple(Vec3(extents))
if spacing is not None:
self.spacing = float(spacing)
# Backend capture state (read/written by IrradianceVolumePass).
self._bake_cursor: int = 0
self._baked: bool = False
self._version: int = 0
# Monotonic re-bake token. Bumped by ``request_update()``; the web renderer
# (which owns its own bake state instead of driving the node's
# ``_baked``/``_bake_cursor`` like the desktop pass does) reads this to know
# a re-bake was requested and ships it as the wire ``revision`` so the
# browser IrradianceVolumePass re-bakes the moved/refreshed grid.
self._request_epoch: int = 0
# --- Public API ---
[docs]
def request_update(self) -> None:
"""Force a full re-bake of the volume on the next frame(s)."""
self._bake_cursor = 0
self._baked = False
self._request_epoch += 1
[docs]
def grid_dims(self) -> tuple[int, int, int]:
"""Probe counts per axis, coarsened so the total stays within MAX_PROBES.
Each axis gets ``floor(2*half / spacing) + 1`` probes (>= 1). If the
product exceeds MAX_PROBES the spacing is effectively raised (counts
scaled down) so the volume keeps spanning its full extents.
"""
half = Vec3(self.extents)
s = max(1e-3, float(self.spacing))
dims = [max(1, int(2.0 * float(h) / s) + 1) for h in (half.x, half.y, half.z)]
while dims[0] * dims[1] * dims[2] > MAX_PROBES:
# Halve the currently-largest axis (keeps the grid roughly cubic).
i = max(range(3), key=lambda k: dims[k])
dims[i] = max(1, dims[i] - 1)
return dims[0], dims[1], dims[2]
[docs]
def probe_count(self) -> int:
nx, ny, nz = self.grid_dims()
return nx * ny * nz
[docs]
def bounds_world(self) -> tuple[Vec3, Vec3]:
"""World AABB of the probe centres (probe (0,0,0) .. probe (nx-1,ny-1,nz-1))."""
half = Vec3(self.extents)
centre = self.world_position
return (
Vec3(centre.x - half.x, centre.y - half.y, centre.z - half.z),
Vec3(centre.x + half.x, centre.y + half.y, centre.z + half.z),
)
[docs]
def probe_positions(self) -> np.ndarray:
"""World positions of every probe, shape (N, 3), index = x + nx*(y + ny*z)."""
nx, ny, nz = self.grid_dims()
lo, hi = self.bounds_world()
xs = np.linspace(lo.x, hi.x, nx, dtype=np.float32) if nx > 1 else np.array([lo.x], dtype=np.float32)
ys = np.linspace(lo.y, hi.y, ny, dtype=np.float32) if ny > 1 else np.array([lo.y], dtype=np.float32)
zs = np.linspace(lo.z, hi.z, nz, dtype=np.float32) if nz > 1 else np.array([lo.z], dtype=np.float32)
# index = x + nx*(y + ny*z): iterate z outer, y, x inner via meshgrid 'ij' then reorder.
gz, gy, gx = np.meshgrid(zs, ys, xs, indexing="ij")
return np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1).astype(np.float32)
# --- Editor gizmo ---
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Wireframe box for the volume extents (12 edges)."""
lo, hi = self.bounds_world()
corners = [Vec3(x, y, z) for x in (lo.x, hi.x) for y in (lo.y, hi.y) for z in (lo.z, hi.z)]
lines: list[tuple[Vec3, Vec3]] = []
for i, a in enumerate(corners):
for b in corners[i + 1 :]:
diff = (
abs(float(a.x) - float(b.x)) > 1e-6,
abs(float(a.y) - float(b.y)) > 1e-6,
abs(float(a.z) - float(b.z)) > 1e-6,
)
if sum(diff) == 1:
lines.append((a, b))
return lines