Source code for simvx.core.irradiance_volume

"""IrradianceVolume3D: baked diffuse global-illumination probe grid.

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 box of baked irradiance probes that gives dynamic objects indirect light. 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 its surface normal, so a character walking through a red-walled room picks up a soft red bounce that flat or sky ambient cannot produce. Outside the box, shading falls back to the ambient the ``WorldEnvironment`` would give it, and the transition cross-fades. The box is axis-aligned and centred on the node's ``world_position``. The node's rotation is deliberately ignored for the grid: probes sample the world, not the node's local frame. ``extents`` are half-extents, so a volume covering a 12 x 8 x 12 room is ``extents=(6, 4, 6)``. Probe count per axis is ``floor(2 * half / spacing) + 1``, capped in total at ``MAX_PROBES``: a grid that would exceed the cap is coarsened (the effective spacing is raised) so the volume still spans its full extents rather than being clipped. Halving ``spacing`` costs roughly eight times the probes, so tune it against :meth:`probe_count`. Baking is amortised at ``update_budget`` probes per frame. ``bake_mode`` ``"once"`` (default) bakes the grid a single time, which is what static level lighting wants; ``"always"`` re-bakes round-robin for scenes whose lighting changes; ``"disabled"`` leaves the volume inert. Call :meth:`request_update` after moving the volume or changing the lighting to force a fresh bake. Example:: vol = IrradianceVolume3D(extents=(6, 4, 6), spacing=2.0) room.add_child(vol) """ # --- 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