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.

Further capabilities (see the BlendScene / AlwaysModeScene classes below):

  • Distance-weighted blending of the two nearest probes (blend_distance edge falloff) so overlapping probes cross-fade instead of hard-switching.

  • capture_mode=”always” with time_slicing=”round_robin”: the probe cubemap refreshes over time (one cube face per frame) as the scene changes.

Run windowed: uv run python examples/features/3d/reflection_probe.py

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