ReflectionProbe3D

Local cubemap reflections inside a probe box.

📄 Docs only

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.

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