Irradiance probe volume

baked diffuse GI colour-bleed onto a moving object.

▶ Run in browser

Tags: 3d gi irradiance-volume probe colour-bleed pbr

An IrradianceVolume3D bakes a grid of diffuse-lighting probes from the scene, each storing the incoming radiance as spherical-harmonic L1 coefficients. A neutral sphere gliding through the volume trilinearly blends the eight probes around it and evaluates their SH for each surface normal, so it picks up soft indirect light baked from the surrounding walls: reddish as it nears the red wall, greenish near the green wall, with a smooth wash in between. Unlike screen-space GI this needs no on-screen source and survives off-screen geometry, and unlike a reflection probe it is a low-frequency DIFFUSE term that reads correctly on rough, matte surfaces.

The grid bakes once over the first few frames (update_budget probes/frame), then lights every subsequent frame for free.

Usage: uv run python examples/features/3d/probe_volume.py

Controls: Space - Toggle the volume on/off Escape - Quit

Source

  1"""Irradiance probe volume: baked diffuse GI colour-bleed onto a moving object.
  2
  3An IrradianceVolume3D bakes a grid of diffuse-lighting probes from
  4the scene, each storing the incoming radiance as spherical-harmonic L1
  5coefficients. A neutral sphere gliding through the volume trilinearly blends the
  6eight probes around it and evaluates their SH for each surface normal, so it
  7picks up soft indirect light baked from the surrounding walls: reddish as it
  8nears the red wall, greenish near the green wall, with a smooth wash in between.
  9Unlike screen-space GI this needs no on-screen source and survives off-screen
 10geometry, and unlike a reflection probe it is a low-frequency DIFFUSE term that
 11reads correctly on rough, matte surfaces.
 12
 13The grid bakes once over the first few frames (``update_budget`` probes/frame),
 14then lights every subsequent frame for free.
 15
 16# /// simvx
 17# tags = ["3d", "gi", "irradiance-volume", "probe", "colour-bleed", "pbr"]
 18# screenshot_frame = 45
 19# ///
 20
 21Usage:
 22    uv run python examples/features/3d/probe_volume.py
 23
 24Controls:
 25    Space   - Toggle the volume on/off
 26    Escape  - Quit
 27"""
 28
 29import math
 30
 31from simvx.core import (
 32    Camera3D,
 33    DirectionalLight3D,
 34    Input,
 35    InputMap,
 36    IrradianceVolume3D,
 37    Key,
 38    Material,
 39    Mesh,
 40    MeshInstance3D,
 41    Node3D,
 42    Text2D,
 43    Vec3,
 44    WorldEnvironment,
 45)
 46from simvx.graphics import App
 47
 48WIDTH, HEIGHT = 1280, 720
 49
 50
 51class ProbeVolumeScene(Node3D):
 52    def __init__(self, **kwargs):
 53        super().__init__(name="ProbeVolumeDemo", **kwargs)
 54
 55        # Camera looking into an open-fronted box so both coloured side walls and
 56        # the moving sphere between them fill the frame.
 57        self.camera = self.add_child(Camera3D(name="Camera", fov=55, near=0.1, far=60.0))
 58        self.camera.position = Vec3(0.0, 2.8, 9.0)
 59        self.camera.look_at(Vec3(0.0, 2.2, -2.0))
 60
 61        # A dim directional key so the walls are lit (the baked radiance the
 62        # volume captures) without washing the scene; flat ambient stays low so
 63        # the baked indirect reads clearly against it.
 64        sun = self.add_child(DirectionalLight3D(name="Sun"))
 65        sun.direction = Vec3(-0.1, -0.94, -0.32)
 66        sun.colour = (1.0, 0.97, 0.92)
 67        sun.intensity = 1.7
 68
 69        white = Material(colour=(0.80, 0.80, 0.80), metallic=0.0, roughness=0.9)
 70        red = Material(colour=(0.90, 0.06, 0.06), metallic=0.0, roughness=1.0)
 71        green = Material(colour=(0.06, 0.85, 0.10), metallic=0.0, roughness=1.0)
 72
 73        span = 5.0
 74        thick = 0.3
 75
 76        def wall(name, mat, position, scale):
 77            self.add_child(MeshInstance3D(name=name, mesh=Mesh.cube(1.0), material=mat, position=position, scale=scale))
 78
 79        wall("Floor", white, Vec3(0, 0, -1), Vec3(span * 2, thick, span * 2))
 80        wall("Ceiling", white, Vec3(0, span, -1), Vec3(span * 2, thick, span * 2))
 81        wall("BackWall", white, Vec3(0, span * 0.5, -1 - span), Vec3(span * 2, span * 2, thick))
 82        wall("LeftWall", red, Vec3(-span, span * 0.5, -1), Vec3(thick, span * 2, span * 2))
 83        wall("RightWall", green, Vec3(span, span * 0.5, -1), Vec3(thick, span * 2, span * 2))
 84
 85        # The moving witness: a neutral matte sphere with NO texture, so its only
 86        # colour comes from lighting. It glides across the box between the walls.
 87        self._sphere = self.add_child(
 88            MeshInstance3D(
 89                name="Sphere",
 90                mesh=Mesh.sphere(1.3, segments=48),
 91                material=Material(colour=(0.85, 0.85, 0.85), metallic=0.0, roughness=0.85),
 92                position=Vec3(0.0, 2.2, -1.0),
 93            )
 94        )
 95
 96        # The probe volume spanning the interior. "once" bakes the whole grid over
 97        # the first few frames, then lights every frame.
 98        self._vol = self.add_child(IrradianceVolume3D(name="Volume", extents=(4.4, 3.2, 4.4), spacing=1.8))
 99        self._vol.position = Vec3(0.0, 3.0, -1.0)
100        self._vol.intensity = 2.4
101        self._vol.update_budget = 24
102
103        self._env = self.add_child(WorldEnvironment(name="Env"))
104        # Very low flat ambient: the baked volume is the dominant fill light, so
105        # the coloured bleed on the sphere is unmistakable.
106        self._env.ambient_light_colour = (0.04, 0.04, 0.05)
107        self._env.ambient_light_energy = 0.5
108
109        self._on = True
110        self._t = 0.0
111        self.add_child(Text2D(text="IRRADIANCE PROBE VOLUME", position=(10, 8), font_scale=1.5))
112        self._status = self.add_child(Text2D(text="VOLUME: ON", position=(10, 40), font_scale=1.3))
113        self._hint = self.add_child(Text2D(text="SPACE:Toggle Volume   ESC:Quit", font_scale=1.1))
114
115    def on_ready(self):
116        InputMap.add_action("toggle_volume", [Key.SPACE])
117        InputMap.add_action("quit", [Key.ESCAPE])
118
119    def on_update(self, dt: float):
120        self._t += dt
121        # Keep the controls hint pinned to the live window's bottom edge.
122        self._hint.position = (10, self.tree.screen_size[1] - 30)
123        # Glide the sphere between the red (left) and green (right) walls so the
124        # baked indirect it picks up sweeps from red through neutral to green.
125        x = -2.8 * math.sin(self._t * 0.9)
126        self._sphere.position = Vec3(x, 2.2, -1.0)
127
128        if Input.is_action_just_pressed("toggle_volume"):
129            self._on = not self._on
130            self._vol.bake_mode = "once" if self._on else "disabled"
131            self._status.text = f"VOLUME: {'ON' if self._on else 'OFF'}"
132        if Input.is_action_just_pressed("quit"):
133            self.app.quit()
134
135
136def main():
137    scene = ProbeVolumeScene()
138    app = App(title="SimVX Irradiance Probe Volume", width=WIDTH, height=HEIGHT)
139    app.run(scene)
140
141
142if __name__ == "__main__":
143    main()