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 (design D10) 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 (design D10) 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._cooldown = 0.0
111        self._t = 0.0
112        self._title = self.add_child(Text2D(text="IRRADIANCE PROBE VOLUME", position=(10, 8), font_scale=1.5))
113        self._status = self.add_child(Text2D(text="VOLUME: ON", position=(10, 40), font_scale=1.3))
114        self.add_child(Text2D(text="SPACE:Toggle Volume   ESC:Quit", position=(10, 690), font_scale=1.1))
115
116    def on_ready(self):
117        InputMap.add_action("toggle_volume", [Key.SPACE])
118        InputMap.add_action("quit", [Key.ESCAPE])
119
120    def on_update(self, dt: float):
121        self._t += dt
122        # Glide the sphere between the red (left) and green (right) walls so the
123        # baked indirect it picks up sweeps from red through neutral to green.
124        x = -2.8 * math.sin(self._t * 0.9)
125        self._sphere.position = Vec3(x, 2.2, -1.0)
126
127        self._cooldown = max(0.0, self._cooldown - dt)
128        if Input.is_action_just_pressed("toggle_volume") and self._cooldown <= 0.0:
129            self._on = not self._on
130            self._cooldown = 0.3
131            self._vol.bake_mode = "once" if self._on else "disabled"
132            self._status.text = f"VOLUME: {'ON' if self._on else 'OFF'}"
133        if Input.is_action_just_pressed("quit"):
134            self.app.quit()
135
136
137def main():
138    scene = ProbeVolumeScene()
139    app = App(title="SimVX Irradiance Probe Volume", width=WIDTH, height=HEIGHT, physics_fps=60)
140    app.run(scene)
141
142
143if __name__ == "__main__":
144    main()