ReflectionProbe3D

local cubemap reflections inside a box.

▶ Run in browser

Tags: 3d reflection ibl probe

Without a probe, a shiny surface reflects the global environment, the sky. A ReflectionProbe3D captures the scene around it into a cubemap, and every surface inside its box reflects that cubemap instead, so a mirror ball in a painted room shows the walls rather than the sky:

ReflectionProbe3D(size=(18, 18, 18), origin_offset=(0, 0.6, 0), box_projection=True)
  • size is the full extent of the probe’s box, centred on the node’s position plus origin_offset, which is also where the cubemap is captured from. A fragment inside the box samples the probe; one outside falls back to the environment.

  • box_projection=True corrects the reflection for the room’s flat walls, so they line up with the geometry instead of looking infinitely far away.

  • blend_distance is a falloff band at the box’s edge. Where two probes overlap, each is weighted by how deep inside its box the fragment is, so they cross-fade instead of switching at a hard line.

  • capture_mode is "once" by default: the cubemap is captured when the probe arrives. "always" keeps capturing, one cube face per frame with the default time_slicing="round_robin" ("every_frame" recaptures all six faces every frame, which costs more), so reflections follow a scene that changes.

Three scenes show one idea each; pick one with the buttons or 1, 2, 3:

  • Room: a mirror ball in a room with red, green, blue and yellow walls.

  • Blend: a red-lit and a green-lit probe overlap at the ball, which reflects a mix of both.

  • Live: an always-mode probe in a room whose walls fade between blue and red; the reflection follows.

Source

  1# /// simvx
  2# tags = ["3d", "reflection", "ibl", "probe"]
  3# screenshot_frame = 8
  4# web = { width = 900, height = 600, responsive = true }
  5# ///
  6"""ReflectionProbe3D: local cubemap reflections inside a box.
  7
  8Without a probe, a shiny surface reflects the global environment, the sky. A
  9``ReflectionProbe3D`` captures the scene around it into a cubemap, and every
 10surface inside its box reflects that cubemap instead, so a mirror ball in a
 11painted room shows the walls rather than the sky:
 12
 13    ReflectionProbe3D(size=(18, 18, 18), origin_offset=(0, 0.6, 0), box_projection=True)
 14
 15- ``size`` is the full extent of the probe's box, centred on the node's
 16  position plus ``origin_offset``, which is also where the cubemap is captured
 17  from. A fragment inside the box samples the probe; one outside falls back to
 18  the environment.
 19- ``box_projection=True`` corrects the reflection for the room's flat walls,
 20  so they line up with the geometry instead of looking infinitely far away.
 21- ``blend_distance`` is a falloff band at the box's edge. Where two probes
 22  overlap, each is weighted by how deep inside its box the fragment is, so
 23  they cross-fade instead of switching at a hard line.
 24- ``capture_mode`` is ``"once"`` by default: the cubemap is captured when the
 25  probe arrives. ``"always"`` keeps capturing, one cube face per frame with the
 26  default ``time_slicing="round_robin"`` (``"every_frame"`` recaptures all six
 27  faces every frame, which costs more), so reflections follow a scene that
 28  changes.
 29
 30Three scenes show one idea each; pick one with the buttons or 1, 2, 3:
 31
 32- **Room**: a mirror ball in a room with red, green, blue and yellow walls.
 33- **Blend**: a red-lit and a green-lit probe overlap at the ball, which
 34  reflects a mix of both.
 35- **Live**: an always-mode probe in a room whose walls fade between blue and
 36  red; the reflection follows.
 37"""
 38
 39import math
 40
 41from simvx.core import (
 42    Anchor,
 43    Button,
 44    ButtonGroup,
 45    Camera3D,
 46    CanvasLayer,
 47    DirectionalLight3D,
 48    HBoxContainer,
 49    Key,
 50    Material,
 51    Mesh,
 52    MeshInstance3D,
 53    Node,
 54    ReflectionProbe3D,
 55    Vec3,
 56    WorldEnvironment,
 57    on_input,
 58)
 59from simvx.graphics import App
 60
 61# The outward normal of each wall of a closed box: floor, ceiling, left, right, back, front.
 62WALLS = ((0, -1, 0), (0, 1, 0), (-1, 0, 0), (1, 0, 0), (0, 0, -1), (0, 0, 1))
 63
 64
 65def closed_box(half, materials, centre=(0, 0, 0), thickness=0.3):
 66    """Six slabs enclosing a cube of half-width ``half``, one material per wall in ``WALLS`` order."""
 67    for normal, material in zip(WALLS, materials, strict=True):
 68        size = tuple(thickness if n else 2 * half for n in normal)
 69        yield MeshInstance3D(mesh=Mesh.box(size), material=material, position=Vec3(normal) * half + Vec3(centre))
 70
 71
 72def mirror_ball(radius, position):
 73    mirror = Material(colour=(0.95, 0.95, 0.95), metallic=1.0, roughness=0.05)
 74    return MeshInstance3D(mesh=Mesh.sphere(radius, rings=48, segments=64), material=mirror, position=position)
 75
 76
 77def matte(colour, glow=None):
 78    return Material(colour=colour, roughness=0.85, emissive_colour=glow)
 79
 80
 81class Room(Node):
 82    """A mirror ball reflecting a room with differently coloured walls."""
 83
 84    def on_ready(self):
 85        self.add_child(WorldEnvironment(environment_map={"colour": (0.20, 0.22, 0.28)}))
 86        self.camera = self.add_child(Camera3D(position=(0, 1.2, 8), fov=60, look_at=(0, 0.6, 0)))
 87        self.add_child(DirectionalLight3D(colour=(1.0, 0.98, 0.92), intensity=1.1, direction=(-4, -9, -6)))
 88        # A fill from the other side lights the green wall, which faces away from the sun.
 89        self.add_child(DirectionalLight3D(colour=(0.95, 0.97, 1.0), intensity=1.0, direction=(4, -9, 6)))
 90
 91        # The ceiling glows: its face points away from both lights and would reflect as a black cap.
 92        ceiling = (0.80, 0.82, 0.88)
 93        walls = [
 94            matte((0.90, 0.80, 0.10)),  # floor: yellow
 95            matte(ceiling, glow=(*ceiling, 0.8)),
 96            matte((0.85, 0.12, 0.12)),  # left: red
 97            matte((0.12, 0.75, 0.20)),  # right: green
 98            matte((0.12, 0.30, 0.90)),  # back: blue
 99            matte((0.75, 0.15, 0.65)),  # front: magenta, behind the camera
100        ]
101        for wall in closed_box(9.0, walls):
102            self.add_child(wall)
103        self.add_child(mirror_ball(2.4, (0, 0.6, -1)))
104        self.add_child(ReflectionProbe3D(size=(18, 18, 18), origin_offset=(0, 0.6, 0), box_projection=True))
105        self.time = 0.0
106
107    def on_update(self, dt):
108        # Sway the camera so the reflected walls sweep across the ball.
109        self.time += dt * 0.4
110        self.camera.position = (math.sin(self.time) * 4.0, 1.2 + math.sin(self.time * 0.5), 8.0)
111        self.camera.look_at((0, 0.6, -0.5))
112
113
114class Blend(Node):
115    """Two overlapping probes, one in a red box and one in a green box, cross-faded at the ball."""
116
117    def on_ready(self):
118        self.add_child(WorldEnvironment(environment_map={"colour": (0.02, 0.02, 0.02)}))
119        self.add_child(Camera3D(position=(0, 1.2, 10), fov=55, look_at=(0, 0.6, 0)))
120        self.add_child(DirectionalLight3D(intensity=1.1, direction=(-2, -9, -6)))
121        for x, colour in ((-6.0, (0.95, 0.05, 0.05)), (6.0, (0.05, 0.95, 0.05))):
122            # A glowing single-colour box, so this probe's cubemap is that colour in every direction.
123            wall = matte(colour, glow=(*colour, 0.9))
124            for slab in closed_box(3.0, [wall] * 6, centre=(x, 0, 0)):
125                self.add_child(slab)
126            # The influence box reaches past the ball, which sits inside both blend bands.
127            self.add_child(ReflectionProbe3D(size=(18, 18, 18), blend_distance=8.0, position=(x, 0.6, 0)))
128        self.add_child(mirror_ball(2.0, (0, 0.6, -1)))
129
130
131class Live(Node):
132    """An always-mode probe keeping up with walls that change colour."""
133
134    BLUE = Vec3(0.10, 0.20, 0.95)
135    RED = Vec3(0.95, 0.08, 0.08)
136
137    def on_ready(self):
138        self.add_child(WorldEnvironment(environment_map={"colour": (0.02, 0.02, 0.02)}))
139        self.add_child(Camera3D(position=(0, 0.6, 6), fov=60, look_at=(0, 0.6, 0)))
140        self.add_child(DirectionalLight3D(intensity=1.2, direction=(-4, -9, -6)))
141        self.add_child(DirectionalLight3D(intensity=0.8, direction=(4, -9, 6)))  # lights the wall behind the camera
142        self.walls = matte(tuple(self.BLUE))
143        for slab in closed_box(7.0, [self.walls] * 6):
144            self.add_child(slab)
145        self.add_child(mirror_ball(2.4, (0, 0.6, -1.5)))
146        self.add_child(
147            ReflectionProbe3D(size=(14, 14, 14), origin_offset=(0, 0.6, 0), box_projection=True, capture_mode="always")
148        )
149        self.time = 0.0
150
151    def on_update(self, dt):
152        self.time += dt
153        # RGBA: an RGB tuple assigned after construction is not expanded the way the constructor expands it.
154        self.walls.colour = (*self.BLUE.lerp(self.RED, 0.5 - 0.5 * math.cos(self.time)), 1.0)
155
156
157SCENES = {"room": Room, "blend": Blend, "live": Live}
158
159
160class ReflectionProbeScene(Node):
161    input_context = {"room": [Key.KEY_1], "blend": [Key.KEY_2], "live": [Key.KEY_3]}
162
163    def on_ready(self):
164        hud = self.add_child(CanvasLayer())
165        bar = hud.add_child(HBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12))
166        group = ButtonGroup()
167        self.buttons = {name: bar.add_child(Button(name.title(), action=name, group=group)) for name in SCENES}
168        self.scene = self.add_child(Room())
169        self.buttons["room"].checked = True
170
171    @on_input(tuple(SCENES))
172    def pick_scene(self, event):
173        self.scene.destroy()
174        self.scene = self.add_child(SCENES[event.action]())
175        self.buttons[event.action].checked = True
176
177
178if __name__ == "__main__":
179    App(title="Reflection Probes", width=1280, height=720).run(ReflectionProbeScene())