Screen-space refraction¶
a glass slab reads the scene behind it and bends it.
▶ Run in browserTags: 3d
A screen-reading material (Material(needs_scene_colour=True)) makes the
renderer split the opaque and transparent phases: after the opaque family draws,
the HDR colour + depth are copied into sampleable textures, then the transparent
slab samples that scene colour (offset by its surface normal) so the colourful
grid behind it appears refracted through the glass. This is the desktop scene
colour/depth copy plumbing (design D1); the offset here is a minimal proof, full
water shading lands with the WaterMaterial pass.
The split is zero-cost when unused: a frame with no screen-reading material records exactly as before (no pass break, no copy).
Run: uv run python examples/features/3d/refraction_probe.py
Controls: A / D - Orbit camera left / right W / S - Zoom in / out Space / Click - Toggle refraction (screen read) on/off
Source¶
1#!/usr/bin/env python3
2"""Screen-space refraction: a glass slab reads the scene behind it and bends it.
3
4# /// simvx
5# screenshot_frame = 30
6# web = { width = 960, height = 720, root = "RefractionProbe", responsive = true }
7# ///
8
9A screen-reading material (``Material(needs_scene_colour=True)``) makes the
10renderer split the opaque and transparent phases: after the opaque family draws,
11the HDR colour + depth are copied into sampleable textures, then the transparent
12slab samples that scene colour (offset by its surface normal) so the colourful
13grid behind it appears refracted through the glass. This is the desktop scene
14colour/depth copy plumbing (design D1); the offset here is a minimal proof, full
15water shading lands with the WaterMaterial pass.
16
17The split is zero-cost when unused: a frame with no screen-reading material
18records exactly as before (no pass break, no copy).
19
20Run: uv run python examples/features/3d/refraction_probe.py
21
22Controls:
23 A / D - Orbit camera left / right
24 W / S - Zoom in / out
25 Space / Click - Toggle refraction (screen read) on/off
26"""
27
28import math
29import sys
30
31from simvx.core import (
32 Camera3D,
33 DirectionalLight3D,
34 Input,
35 InputMap,
36 Key,
37 Material,
38 Mesh,
39 MeshInstance3D,
40 MouseButton,
41 Node3D,
42 PointLight3D,
43 Quat,
44 Text2D,
45 Vec3,
46 WorldEnvironment,
47)
48from simvx.graphics import App
49
50WIDTH, HEIGHT = 960, 720
51
52_GRID_COLOURS = [
53 (0.90, 0.20, 0.20),
54 (0.20, 0.85, 0.30),
55 (0.20, 0.45, 0.95),
56 (0.95, 0.80, 0.20),
57 (0.85, 0.30, 0.85),
58 (0.25, 0.85, 0.85),
59]
60
61
62class RefractionProbe(Node3D):
63 def __init__(self, **kwargs):
64 super().__init__(name="RefractionProbe", **kwargs)
65
66 self._cam_angle = 90.0
67 self._cam_height = 0.5
68 self._cam_dist = 7.0
69 self.camera = self.add_child(Camera3D(name="Camera", fov=55, near=0.1, far=100.0))
70 self._update_camera()
71
72 sun = self.add_child(DirectionalLight3D(name="Sun"))
73 sun.colour = (1.0, 0.96, 0.9)
74 sun.intensity = 1.6
75 sun.rotation = Quat.from_euler(math.radians(-45), math.radians(-25), 0)
76 fill = self.add_child(PointLight3D(name="Fill", position=Vec3(-4, 3, 5)))
77 fill.colour = (0.5, 0.6, 0.9)
78 fill.intensity = 0.6
79 fill.range = 20.0
80
81 # Opaque colourful grid behind the glass: the refraction target.
82 k = 0
83 for gy in (1.4, 0.0, -1.4):
84 for gx in (-2.0, 0.0, 2.0):
85 self.add_child(
86 MeshInstance3D(
87 name=f"Tile{k}",
88 mesh=Mesh.cube(1.2),
89 material=Material(colour=(*_GRID_COLOURS[k % len(_GRID_COLOURS)], 1.0), roughness=0.55),
90 position=Vec3(gx, gy, -1.2),
91 )
92 )
93 k += 1
94
95 # Screen-reading glass slab in front of the grid.
96 self._glass_mat = Material(
97 colour=(0.75, 0.87, 1.0, 0.55),
98 blend="alpha",
99 roughness=0.05,
100 metallic=0.0,
101 needs_scene_colour=True,
102 )
103 self.add_child(
104 MeshInstance3D(
105 name="Glass",
106 mesh=Mesh.cube(1.0),
107 material=self._glass_mat,
108 position=Vec3(0.0, 0.0, 1.6),
109 scale=Vec3(4.2, 3.4, 0.08),
110 )
111 )
112
113 env = self.add_child(WorldEnvironment(name="Env"))
114 env.bloom_enabled = True
115
116 self._refract_on = True
117 self._toggle_cd = 0.0
118 self.add_child(Text2D(text="SCREEN-SPACE REFRACTION", position=(10, 8), font_scale=1.5))
119 self._status = self.add_child(Text2D(text="Refraction: ON", position=(10, 40), font_scale=1.2))
120 self._hint = self.add_child(
121 Text2D(text="SPACE/CLICK:Toggle A/D:Orbit W/S:Zoom", position=(10, HEIGHT - 30), font_scale=1.0)
122 )
123
124 def on_ready(self):
125 InputMap.add_action("cam_left", [Key.A, Key.LEFT])
126 InputMap.add_action("cam_right", [Key.D, Key.RIGHT])
127 InputMap.add_action("cam_fwd", [Key.W, Key.UP])
128 InputMap.add_action("cam_back", [Key.S, Key.DOWN])
129 # Mouse/touch fallback: touch arrives as MouseButton.LEFT on web.
130 InputMap.add_action("toggle_refract", [Key.SPACE, MouseButton.LEFT])
131
132 def _update_camera(self):
133 rad = math.radians(self._cam_angle)
134 x = math.cos(rad) * self._cam_dist
135 z = math.sin(rad) * self._cam_dist
136 self.camera.position = Vec3(x, self._cam_height, z)
137 self.camera.look_at(Vec3(0, 0, 0))
138
139 def on_fixed_update(self, dt: float):
140 speed = 45.0
141 if Input.is_action_pressed("cam_left"):
142 self._cam_angle += speed * dt
143 if Input.is_action_pressed("cam_right"):
144 self._cam_angle -= speed * dt
145 if Input.is_action_pressed("cam_fwd"):
146 self._cam_dist = max(4.0, self._cam_dist - 8 * dt)
147 if Input.is_action_pressed("cam_back"):
148 self._cam_dist = min(14.0, self._cam_dist + 8 * dt)
149 self._update_camera()
150
151 def on_update(self, dt: float):
152 # Keep the controls hint pinned to the live window's bottom edge.
153 self._hint.position = (10, self.app.height - 30)
154 self._toggle_cd = max(0.0, self._toggle_cd - dt)
155 if Input.is_action_just_pressed("toggle_refract") and self._toggle_cd <= 0:
156 self._refract_on = not self._refract_on
157 self._toggle_cd = 0.3
158 # Flipping needs_scene_colour on the live material sets/clears the
159 # screen-read bit, so the next frame records with/without the split.
160 self._glass_mat.needs_scene_colour = self._refract_on
161 self._status.text = f"Refraction: {'ON' if self._refract_on else 'OFF'}"
162
163
164def _selftest() -> bool:
165 """Headless self-check: the screen-read split renders a non-blank frame and
166 the refraction actually bends pixels vs the same scene with it off."""
167 import numpy as np
168
169 from simvx.graphics.testing import assert_not_blank, save_png
170
171 def render(screen_read: bool):
172 scene = RefractionProbe()
173 scene._glass_mat.needs_scene_colour = screen_read
174 app = App(width=WIDTH // 2, height=HEIGHT // 2, title="refraction", visible=False)
175 frame = app.run_headless(scene, frames=6, capture_frames=[5])[-1]
176 return np.asarray(frame)[..., :3].astype(np.int32)
177
178 on = render(True)
179 off = render(False)
180 assert_not_blank(on)
181 save_png("/tmp/refraction_probe_test.png", on.astype("uint8"))
182 diff = np.abs(on - off).sum(-1)
183 changed = float((diff > 4).mean())
184 print(f"refraction changed-pixel fraction = {changed:.3f} (max diff {int(np.abs(on - off).max())})")
185 print("screenshot: /tmp/refraction_probe_test.png")
186 return changed > 0.02
187
188
189def main():
190 scene = RefractionProbe()
191 app = App(title="SimVX Refraction Probe", width=WIDTH, height=HEIGHT, physics_fps=60)
192 app.run(scene)
193
194
195if __name__ == "__main__":
196 if "--test" in sys.argv:
197 sys.exit(0 if _selftest() else 1)
198 main()