ReflectionProbe3D¶
Local cubemap reflections inside a probe box.
📄 Docs onlyTags: 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.
Phase-1 desktop renderer feature:
Scene captured six times from the probe origin into a cubemap.
The engine’s existing 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.
Run windowed: uv run python examples/features/3d/reflection_probe.py Run headless: uv run python examples/features/3d/reflection_probe.py –test
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
9Phase-1 desktop renderer feature:
10 - Scene captured six times from the probe origin into a cubemap.
11 - The engine's existing split-sum IBL precompute (irradiance + prefiltered
12 specular) 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
16Run windowed: uv run python examples/features/3d/reflection_probe.py
17Run headless: uv run python examples/features/3d/reflection_probe.py --test
18
19Controls:
20 Escape - Quit
21
22# /// simvx
23# tags = ["3d", "reflection", "ibl", "probe"]
24# screenshot_frame = 8
25# web = { disabled = true, reason = "Reflection probes are a desktop-only renderer feature." }
26# ///
27"""
28
29import math
30import sys
31
32from simvx.core import (
33 Camera3D,
34 DirectionalLight3D,
35 Input,
36 InputMap,
37 Key,
38 Material,
39 Mesh,
40 MeshInstance3D,
41 Node,
42 ReflectionProbe3D,
43 Text2D,
44 Vec3,
45 WorldEnvironment,
46)
47from simvx.graphics import App
48
49ROOM = 8.0 # room half-extent
50
51
52class ReflectionProbeScene(Node):
53 def on_ready(self):
54 InputMap.add_action("quit", [Key.ESCAPE])
55
56 # Faint neutral global skybox so surfaces OUTSIDE the probe box still
57 # have an environment to reflect (a dim grey-blue sky). The local probe
58 # reflection should look obviously different (saturated room colours).
59 self.add_child(WorldEnvironment(environment_map={"colour": (0.20, 0.22, 0.28)}))
60
61 # Camera sits INSIDE the room near the (low) front wall, looking at the
62 # sphere, so the back/side coloured walls fill the view and reflect.
63 cam = self.add_child(Camera3D(
64 position=(0, -6.5, 1.0), fov=60, near=0.1, far=200.0,
65 look_at=Vec3(0, 0, 0.6), up=Vec3(0, 0, 1),
66 ))
67 self._cam = cam
68
69 sun = DirectionalLight3D(position=(4, -6, 9))
70 sun.colour = (1.0, 0.98, 0.92)
71 sun.intensity = 1.1
72 sun.look_at(Vec3(0, 0, 0))
73 self.add_child(sun)
74
75 self._build_room()
76
77 # Reflective sphere: full metallic, near-mirror finish so the local
78 # reflection dominates the shading.
79 sphere = Mesh.sphere(2.4, rings=48, segments=64)
80 mirror = Material(colour=(0.95, 0.95, 0.95, 1.0), metallic=1.0, roughness=0.05)
81 self.add_child(MeshInstance3D(mesh=sphere, material=mirror, position=(0, 1.0, 0.6)))
82
83 # Reflection probe whose box encloses the whole room, capturing the
84 # coloured walls. box_projection makes the flat walls reflect correctly.
85 probe = ReflectionProbe3D(
86 size=(ROOM, ROOM, ROOM),
87 origin_offset=(0, 0, 0.6),
88 box_projection=True,
89 intensity=1.0,
90 position=(0, 0, 0.0),
91 )
92 self.add_child(probe)
93 self._probe = probe
94
95 self.add_child(Text2D(text="ReflectionProbe3D: local cubemap reflections", x=12, y=12, font_scale=2.0))
96 self.add_child(Text2D(text="Mirror sphere reflects the coloured room, not the sky", x=12, y=58, font_scale=1.6))
97
98 self._time = 0.0
99
100 def _wall(self, colour, position, scale):
101 mat = Material(colour=(*colour, 1.0), metallic=0.0, roughness=0.85)
102 self.add_child(MeshInstance3D(mesh=Mesh.cube(1.0), material=mat, position=position, scale=Vec3(*scale)))
103
104 def _build_room(self):
105 t = 0.3 # wall thickness
106 # Yellow floor + light ceiling. The front (toward camera) wall is omitted
107 # so the camera sees into the room; the other walls reflect off the sphere.
108 self._wall((0.90, 0.80, 0.10), (0, 0, -ROOM), (ROOM, ROOM, t)) # floor = yellow
109 self._wall((0.55, 0.55, 0.60), (0, 0, ROOM), (ROOM, ROOM, t)) # ceiling
110 self._wall((0.85, 0.12, 0.12), (-ROOM, 0, 0), (t, ROOM, ROOM)) # left = red
111 self._wall((0.12, 0.75, 0.20), (ROOM, 0, 0), (t, ROOM, ROOM)) # right = green
112 self._wall((0.12, 0.30, 0.90), (0, ROOM, 0), (ROOM, t, ROOM)) # back = blue
113
114 def on_process(self, dt):
115 if Input.is_action_just_pressed("quit"):
116 self.app.quit()
117 return
118 # Gentle side-to-side sway near the open front of the room, so the
119 # reflected red/green/blue walls sweep across the mirror sphere while the
120 # camera stays inside (never orbiting behind a wall).
121 self._time += dt * 0.4
122 self._cam.position = Vec3(
123 math.sin(self._time) * 4.0,
124 -6.5,
125 1.0 + math.sin(self._time * 0.5) * 1.0,
126 )
127 self._cam.look_at(Vec3(0, 0.5, 0.6), up=Vec3(0, 0, 1))
128
129
130def _run_test():
131 """Headless smoke test: render a few frames and assert the sphere reflects the room."""
132 import numpy as np
133
134 from simvx.graphics import save_png
135
136 app = App(title="ReflectionProbe", width=900, height=600, visible=False)
137 frames = app.run_headless(ReflectionProbeScene(), frames=8, capture_frames=[7])
138 img = frames[0]
139 save_png("/tmp/verify/reflection_probe/with_probe.png", img)
140
141 # The mirror sphere must reflect the SATURATED room walls (red left, green
142 # right, yellow floor), not the flat grey global sky. Sample three patches
143 # on the sphere's lower ring where those reflections land and assert each
144 # carries a strong dominant hue (one channel clearly above the others).
145 h, w = img.shape[:2]
146 cx, cy = w // 2, h // 2
147 # (patch, dominant channel index): red=0, green=1, yellow=>R&G over B.
148 samples = [
149 ("left/red", img[cy:cy + 50, cx - 110:cx - 70, :3], 0),
150 ("right/green", img[cy:cy + 50, cx + 70:cx + 110, :3], 1),
151 ("floor/yellow", img[cy + 90:cy + 130, cx - 30:cx + 30, :3], None),
152 ]
153 matched = 0
154 for name, patch, dom in samples:
155 r, g, b = patch.astype(np.float32).reshape(-1, 3).mean(axis=0)
156 if dom == 0:
157 ok = r > g * 1.5 and r > b * 1.5
158 elif dom == 1:
159 ok = g > r * 1.5 and g > b * 1.5
160 else: # yellow: red & green both dominate blue
161 ok = r > b * 1.5 and g > b * 1.5
162 print(f"[test] {name} reflection rgb=[{r:.1f},{g:.1f},{b:.1f}] hue_match={ok}")
163 matched += int(bool(ok))
164 print("[test] wrote /tmp/verify/reflection_probe/with_probe.png")
165 assert matched >= 2, "mirror sphere is not reflecting the coloured room (probe IBL missing)"
166 print(f"[test] PASS ({matched}/3 reflection patches match the expected room hue)")
167
168
169if __name__ == "__main__":
170 if "--test" in sys.argv:
171 _run_test()
172 else:
173 App(title="ReflectionProbe Demo", width=1280, height=720).run(ReflectionProbeScene())