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.
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())