ReflectionProbe3D¶
Local cubemap reflections inside a probe box.
▶ Run in browserTags: 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]())