ReflectionProbe3D

Local cubemap reflections inside a probe box.

▶ Run in browser

Tags: 3d reflection ibl probe

A mirror-finish sphere sits inside a ReflectionProbe3D placed in a room with vividly coloured interior walls (red/green/blue/yellow). The probe captures the room into a local cubemap, so the sphere reflects the room colours, clearly different from the faint global sky reflection a sphere would otherwise pick up.

How it works:

  • Scene captured six times from the probe origin into a cubemap.

  • The engine’s split-sum IBL precompute (irradiance + prefiltered specular) runs per-probe; results land in a shared cubemap array.

  • Fragments inside the probe box sample the local probe IBL (box-projected reflection); fragments outside fall back to the global environment IBL.

Two further scenes live in this file, each selected with --scene:

  • blend: distance-weighted blending of the two nearest probes (blend_distance edge falloff) so overlapping probes cross-fade instead of hard-switching. A mirror sphere in the overlap reflects both hues.

  • dynamic: capture_mode=”always” with time_slicing=”round_robin”, so the probe cubemap refreshes over time (one cube face per frame) as the scene changes: the room flips blue to red and the reflection follows.

Run windowed: uv run python examples/features/3d/reflection_probe.py uv run python examples/features/3d/reflection_probe.py –scene blend uv run python examples/features/3d/reflection_probe.py –scene dynamic

Controls: Escape - Quit

Source

  1"""
  2ReflectionProbe3D: Local cubemap reflections inside a probe box.
  3
  4A mirror-finish sphere sits inside a ReflectionProbe3D placed in a room with
  5vividly coloured interior walls (red/green/blue/yellow). The probe captures the
  6room into a local cubemap, so the sphere reflects the *room* colours, clearly
  7different from the faint global sky reflection a sphere would otherwise pick up.
  8
  9How it works:
 10  - Scene captured six times from the probe origin into a cubemap.
 11  - The engine's split-sum IBL precompute (irradiance + prefiltered specular)
 12    runs per-probe; results land in a shared cubemap array.
 13  - Fragments inside the probe box sample the local probe IBL (box-projected
 14    reflection); fragments outside fall back to the global environment IBL.
 15
 16Two further scenes live in this file, each selected with ``--scene``:
 17  - ``blend``: distance-weighted blending of the two nearest probes
 18    (blend_distance edge falloff) so overlapping probes cross-fade instead of
 19    hard-switching. A mirror sphere in the overlap reflects both hues.
 20  - ``dynamic``: capture_mode="always" with time_slicing="round_robin", so the
 21    probe cubemap refreshes over time (one cube face per frame) as the scene
 22    changes: the room flips blue to red and the reflection follows.
 23
 24Run windowed:   uv run python examples/features/3d/reflection_probe.py
 25                uv run python examples/features/3d/reflection_probe.py --scene blend
 26                uv run python examples/features/3d/reflection_probe.py --scene dynamic
 27
 28Controls:
 29    Escape  - Quit
 30
 31# /// simvx
 32# tags = ["3d", "reflection", "ibl", "probe"]
 33# screenshot_frame = 8
 34# web = { root = "ReflectionProbeScene", width = 900, height = 600, responsive = true }
 35# ///
 36"""
 37
 38import argparse
 39import math
 40
 41from simvx.core import (
 42    Camera3D,
 43    DirectionalLight3D,
 44    Input,
 45    InputMap,
 46    Key,
 47    Material,
 48    Mesh,
 49    MeshInstance3D,
 50    Node,
 51    ReflectionProbe3D,
 52    Text2D,
 53    Vec3,
 54    WorldEnvironment,
 55)
 56from simvx.graphics import App
 57
 58ROOM = 9.0  # room half-extent
 59
 60
 61class ReflectionProbeScene(Node):
 62    def on_ready(self):
 63        InputMap.add_action("quit", [Key.ESCAPE])
 64
 65        # Faint neutral global skybox so surfaces OUTSIDE the probe box still
 66        # have an environment to reflect (a dim grey-blue sky). The local probe
 67        # reflection should look obviously different (saturated room colours).
 68        self.add_child(WorldEnvironment(environment_map={"colour": (0.20, 0.22, 0.28)}))
 69
 70        # Camera sits INSIDE the room near the (low) front wall, looking at the
 71        # sphere, so the back/side coloured walls fill the view and reflect.
 72        cam = self.add_child(
 73            Camera3D(
 74                position=(0, -8.0, 1.2),
 75                fov=60,
 76                near=0.1,
 77                far=200.0,
 78                look_at=Vec3(0, 0, 0.6),
 79                up=Vec3(0, 0, 1),
 80            )
 81        )
 82        self._cam = cam
 83
 84        sun = DirectionalLight3D(position=(4, -6, 9))
 85        sun.colour = (1.0, 0.98, 0.92)
 86        sun.intensity = 1.1
 87        sun.look_at(Vec3(0, 0, 0))
 88        self.add_child(sun)
 89
 90        # Fill light from the -X / +Y side. The single sun (on the +X side) lights
 91        # the red, blue and floor inner faces but leaves the green wall's inner
 92        # face (-X normal) in shadow, so it reflected almost black. This fill hits
 93        # the green wall without double-lighting the blue wall (whose -Y inner face
 94        # faces away from it), keeping the reflected colours balanced.
 95        fill = DirectionalLight3D(position=(-4, 6, 9))
 96        fill.colour = (0.95, 0.97, 1.0)
 97        fill.intensity = 1.0
 98        fill.look_at(Vec3(0, 0, 0))
 99        self.add_child(fill)
100
101        self._build_room()
102
103        # Reflective sphere: full metallic, near-mirror finish so the local
104        # reflection dominates the shading.
105        sphere = Mesh.sphere(2.4, rings=48, segments=64)
106        mirror = Material(colour=(0.95, 0.95, 0.95, 1.0), metallic=1.0, roughness=0.05)
107        self.add_child(MeshInstance3D(mesh=sphere, material=mirror, position=(0, 1.0, 0.6)))
108
109        # Reflection probe whose box encloses the whole room, capturing the
110        # coloured walls. box_projection makes the flat walls reflect correctly.
111        probe = ReflectionProbe3D(
112            size=(ROOM * 2, ROOM * 2, ROOM * 2),
113            origin_offset=(0, 0, 0.6),
114            box_projection=True,
115            intensity=1.0,
116            position=(0, 0, 0.0),
117        )
118        self.add_child(probe)
119        self._probe = probe
120
121        self.add_child(Text2D(text="ReflectionProbe3D: local cubemap reflections", position=(12, 12), font_scale=2.0))
122        self.add_child(
123            Text2D(text="Mirror sphere reflects the coloured room, not the sky", position=(12, 58), font_scale=1.6)
124        )
125
126        self._time = 0.0
127
128    def _wall(self, colour, position, scale, emissive=None):
129        mat = Material(colour=(*colour, 1.0), metallic=0.0, roughness=0.85, emissive_colour=emissive)
130        self.add_child(MeshInstance3D(mesh=Mesh.cube(1.0), material=mat, position=position, scale=Vec3(*scale)))
131
132    def _build_room(self):
133        t = 0.3  # wall thickness
134        # A fully enclosed box so the mirror sphere reflects a wall in every
135        # direction (no dark gap). The camera sits just inside the front wall,
136        # which it never sees directly but the sphere reflects, so the sphere's
137        # centre shows the magenta front wall instead of the dark opening. The
138        # ceiling is an emissive skylight: its inner (-Z) face points away from
139        # both directional lights, so without the glow the sphere's top reflects
140        # a dark void. Neutral grey emissive adds no hue to bleed into the
141        # coloured patches.
142        ceiling = (0.80, 0.82, 0.88)
143        self._wall((0.90, 0.80, 0.10), (0, 0, -ROOM), (ROOM, ROOM, t))  # floor = yellow
144        self._wall(ceiling, (0, 0, ROOM), (ROOM, ROOM, t), emissive=(*ceiling, 0.8))  # ceiling skylight
145        self._wall((0.85, 0.12, 0.12), (-ROOM, 0, 0), (t, ROOM, ROOM))  # left  = red
146        self._wall((0.12, 0.75, 0.20), (ROOM, 0, 0), (t, ROOM, ROOM))  # right = green
147        self._wall((0.12, 0.30, 0.90), (0, ROOM, 0), (ROOM, t, ROOM))  # back  = blue
148        self._wall((0.75, 0.15, 0.65), (0, -ROOM, 0), (ROOM, t, ROOM))  # front = magenta (behind camera)
149
150    def on_update(self, dt):
151        if Input.is_action_just_pressed("quit"):
152            self.app.quit()
153            return
154        # Gentle side-to-side sway near the open front of the room, so the
155        # reflected red/green/blue walls sweep across the mirror sphere while the
156        # camera stays inside (never orbiting behind a wall).
157        self._time += dt * 0.4
158        self._cam.position = Vec3(
159            math.sin(self._time) * 4.0,
160            -8.0,
161            1.2 + math.sin(self._time * 0.5) * 1.0,
162        )
163        self._cam.look_at(Vec3(0, 0.5, 0.6), up=Vec3(0, 0, 1))
164
165
166# ---------------------------------------------------------------------------
167# Distance-weighted 2-probe blending
168# ---------------------------------------------------------------------------
169
170
171class BlendScene(Node):
172    """Two overlapping probes: a red-lit box and a green-lit box.
173
174    Each probe sits inside a fully-enclosed single-colour cube (all red / all
175    green), so its cubemap is that colour in EVERY direction. A mirror sphere
176    sits in the OVERLAP band where both probe influence boxes cover it (within
177    each other's ``blend_distance``). With the old hard cutoff the sphere would
178    reflect exactly one probe (pure red OR pure green); with distance-weighted
179    blending it reflects a MIX, so both R and G are elevated regardless of the
180    per-fragment reflection direction.
181    """
182
183    def on_ready(self):
184        InputMap.add_action("quit", [Key.ESCAPE])
185        self.add_child(WorldEnvironment(environment_map={"colour": (0.02, 0.02, 0.02)}))
186
187        self.add_child(
188            Camera3D(
189                position=(0, -10.0, 1.2),
190                fov=55,
191                near=0.1,
192                far=200.0,
193                look_at=Vec3(0, 0, 0.6),
194                up=Vec3(0, 0, 1),
195            )
196        )
197        sun = DirectionalLight3D(position=(2, -6, 9))
198        sun.colour = (1.0, 1.0, 1.0)
199        sun.intensity = 1.1
200        sun.look_at(Vec3(0, 0, 0))
201        self.add_child(sun)
202
203        # Two fully-closed single-colour cubes, off to either side, each enclosing
204        # one probe so its cubemap is that colour in every direction.
205        self._room((0.95, 0.05, 0.05), centre_x=-6.0)  # red cube around probe A
206        self._room((0.05, 0.95, 0.05), centre_x=+6.0)  # green cube around probe B
207
208        # Mirror sphere in the centre overlap band.
209        sphere = Mesh.sphere(2.0, rings=48, segments=64)
210        mirror = Material(colour=(0.95, 0.95, 0.95, 1.0), metallic=1.0, roughness=0.04)
211        self.add_child(MeshInstance3D(mesh=sphere, material=mirror, position=(0, 1.0, 0.6)))
212
213        # Two probes whose boxes overlap across the centre, each with a wide
214        # blend_distance so the centre sits in the cross-fade region of both. The
215        # influence box (18 across) reaches the sphere; the capture room (6 across) is
216        # tight around each probe so the cubemap is a single saturated hue.
217        for cx in (-6.0, 6.0):
218            probe = ReflectionProbe3D(
219                size=(18.0, 18.0, 18.0),
220                box_projection=False,
221                intensity=1.0,
222                blend_distance=8.0,
223                position=(cx, 0, 0.6),
224            )
225            self.add_child(probe)
226
227        self.add_child(
228            Text2D(
229                text="ReflectionProbe3D blending: red probe + green probe overlap", position=(12, 12), font_scale=1.8
230            )
231        )
232
233    def _room(self, colour, centre_x):
234        t = 0.3
235        hw = 3.0  # half-width of each closed colour cube
236        c = (*colour, 1.0)
237
238        def wall(pos, scale):
239            # Self-lit walls (emissive): the closed cube blocks the sun and the
240            # scene's environment is near-black, so emissive keeps the captured
241            # cubemap a saturated hue regardless of the installed environment IBL.
242            mat = Material(colour=c, metallic=0.0, roughness=0.85, emissive_colour=(*colour, 0.9))
243            self.add_child(MeshInstance3D(mesh=Mesh.cube(1.0), material=mat, position=pos, scale=Vec3(*scale)))
244
245        wall((centre_x, 0, -hw), (hw, hw, t))  # floor
246        wall((centre_x, 0, hw), (hw, hw, t))  # ceiling
247        wall((centre_x - hw, 0, 0), (t, hw, hw))  # outer side
248        wall((centre_x + hw, 0, 0), (t, hw, hw))  # inner side
249        wall((centre_x, -hw, 0), (hw, t, hw))  # front
250        wall((centre_x, hw, 0), (hw, t, hw))  # back
251
252    def on_update(self, dt):
253        if Input.is_action_just_pressed("quit"):
254            self.app.quit()
255
256
257# ---------------------------------------------------------------------------
258# capture_mode="always" round-robin refresh
259# ---------------------------------------------------------------------------
260
261
262class AlwaysModeScene(Node):
263    """An always-mode probe whose captured scene changes over time.
264
265    The whole room (a closed cube enclosing both probe and camera) starts BLUE
266    and flips to RED after the first capture settles. With round-robin refresh
267    the probe re-captures over the following frames, so the mirror sphere's
268    reflected hue migrates from blue toward red. A uniform-colour box makes the
269    reflected hue direction-independent, so the test reads a clean blue->red
270    shift regardless of which way each sphere fragment reflects.
271    """
272
273    def on_ready(self):
274        InputMap.add_action("quit", [Key.ESCAPE])
275        self.add_child(WorldEnvironment(environment_map={"colour": (0.02, 0.02, 0.02)}))
276        # Camera INSIDE the closed cube, near the front wall, looking at the sphere.
277        self.add_child(
278            Camera3D(
279                position=(0, -6.0, 0.6),
280                fov=60,
281                near=0.1,
282                far=200.0,
283                look_at=Vec3(0, 0, 0.6),
284                up=Vec3(0, 0, 1),
285            )
286        )
287        sun = DirectionalLight3D(position=(4, -6, 9))
288        sun.colour = (1.0, 1.0, 1.0)
289        sun.intensity = 1.2
290        sun.look_at(Vec3(0, 0, 0))
291        self.add_child(sun)
292
293        t = 0.3
294        hw = 7.0
295        self._walls = []
296
297        def wall(pos, scale):
298            mat = Material(colour=(0.10, 0.20, 0.95, 1.0), metallic=0.0, roughness=0.85)
299            self.add_child(MeshInstance3D(mesh=Mesh.cube(1.0), material=mat, position=pos, scale=Vec3(*scale)))
300            self._walls.append(mat)
301
302        # Fully-closed uniform-colour cube enclosing probe + camera + sphere.
303        wall((0, 0, -hw), (hw, hw, t))  # floor
304        wall((0, 0, hw), (hw, hw, t))  # ceiling
305        wall((-hw, 0, 0), (t, hw, hw))  # left
306        wall((hw, 0, 0), (t, hw, hw))  # right
307        wall((0, hw, 0), (hw, t, hw))  # back
308        wall((0, -hw, 0), (hw, t, hw))  # front (behind the camera)
309
310        sphere = Mesh.sphere(2.4, rings=48, segments=64)
311        mirror = Material(colour=(0.95, 0.95, 0.95, 1.0), metallic=1.0, roughness=0.05)
312        self.add_child(MeshInstance3D(mesh=sphere, material=mirror, position=(0, 1.5, 0.6)))
313
314        self.add_child(
315            ReflectionProbe3D(
316                size=(hw * 2, hw * 2, hw * 2),
317                origin_offset=(0, 0, 0.6),
318                box_projection=True,
319                intensity=1.0,
320                capture_mode="always",
321                position=(0, 0, 0.0),
322            )
323        )
324        self._frame = 0
325
326    def on_update(self, dt):
327        if Input.is_action_just_pressed("quit"):
328            self.app.quit()
329        self._frame += 1
330        # After the initial capture settles, flip the whole room blue -> red.
331        if self._frame == 8:
332            for mat in self._walls:
333                mat.colour = (0.95, 0.08, 0.08, 1.0)
334
335
336SCENES = {"room": ReflectionProbeScene, "blend": BlendScene, "dynamic": AlwaysModeScene}
337
338
339if __name__ == "__main__":
340    parser = argparse.ArgumentParser(description="ReflectionProbe3D reference scenes")
341    parser.add_argument("--scene", choices=tuple(SCENES), default="room", help="which probe scene to run")
342    scene_name = parser.parse_args().scene
343    App(title=f"ReflectionProbe Demo ({scene_name})", width=1280, height=720).run(SCENES[scene_name]())