Source code for simvx.core.irradiance_volume

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