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. The normal-driven offset here
is the whole of the effect: WaterSurface3D builds its full water shading on
this same scene-colour read, see water.py.
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. The normal-driven offset here
14is the whole of the effect: ``WaterSurface3D`` builds its full water shading on
15this same scene-colour read, see ``water.py``.
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
29
30from simvx.core import (
31 Camera3D,
32 DirectionalLight3D,
33 Input,
34 InputMap,
35 Key,
36 Material,
37 Mesh,
38 MeshInstance3D,
39 MouseButton,
40 Node3D,
41 PointLight3D,
42 Quat,
43 Text2D,
44 Vec3,
45 WorldEnvironment,
46)
47from simvx.graphics import App
48
49WIDTH, HEIGHT = 960, 720
50
51_GRID_COLOURS = [
52 (0.90, 0.20, 0.20),
53 (0.20, 0.85, 0.30),
54 (0.20, 0.45, 0.95),
55 (0.95, 0.80, 0.20),
56 (0.85, 0.30, 0.85),
57 (0.25, 0.85, 0.85),
58]
59
60
61class RefractionProbe(Node3D):
62 def __init__(self, **kwargs):
63 super().__init__(name="RefractionProbe", **kwargs)
64
65 self._cam_angle = 90.0
66 self._cam_height = 0.5
67 self._cam_dist = 7.0
68 self.camera = self.add_child(Camera3D(name="Camera", fov=55, near=0.1, far=100.0))
69 self._update_camera()
70
71 sun = self.add_child(DirectionalLight3D(name="Sun"))
72 sun.colour = (1.0, 0.96, 0.9)
73 sun.intensity = 1.6
74 sun.rotation = Quat.from_euler(math.radians(-45), math.radians(-25), 0)
75 fill = self.add_child(PointLight3D(name="Fill", position=Vec3(-4, 3, 5)))
76 fill.colour = (0.5, 0.6, 0.9)
77 fill.intensity = 0.6
78 fill.range = 20.0
79
80 # Opaque colourful grid behind the glass: the refraction target.
81 k = 0
82 for gy in (1.4, 0.0, -1.4):
83 for gx in (-2.0, 0.0, 2.0):
84 self.add_child(
85 MeshInstance3D(
86 name=f"Tile{k}",
87 mesh=Mesh.cube(1.2),
88 material=Material(colour=(*_GRID_COLOURS[k % len(_GRID_COLOURS)], 1.0), roughness=0.55),
89 position=Vec3(gx, gy, -1.2),
90 )
91 )
92 k += 1
93
94 # Screen-reading glass slab in front of the grid.
95 self._glass_mat = Material(
96 colour=(0.75, 0.87, 1.0, 0.55),
97 blend="alpha",
98 roughness=0.05,
99 metallic=0.0,
100 needs_scene_colour=True,
101 )
102 self.add_child(
103 MeshInstance3D(
104 name="Glass",
105 mesh=Mesh.cube(1.0),
106 material=self._glass_mat,
107 position=Vec3(0.0, 0.0, 1.6),
108 scale=Vec3(4.2, 3.4, 0.08),
109 )
110 )
111
112 env = self.add_child(WorldEnvironment(name="Env"))
113 env.bloom_enabled = True
114
115 self._refract_on = True
116 self.add_child(Text2D(text="SCREEN-SPACE REFRACTION", position=(10, 8), font_scale=1.5))
117 self._status = self.add_child(Text2D(text="Refraction: ON", position=(10, 40), font_scale=1.2))
118 self._hint = self.add_child(
119 Text2D(text="SPACE/CLICK:Toggle A/D:Orbit W/S:Zoom", position=(10, HEIGHT - 30), font_scale=1.0)
120 )
121
122 def on_ready(self):
123 InputMap.add_action("cam_left", [Key.A, Key.LEFT])
124 InputMap.add_action("cam_right", [Key.D, Key.RIGHT])
125 InputMap.add_action("cam_fwd", [Key.W, Key.UP])
126 InputMap.add_action("cam_back", [Key.S, Key.DOWN])
127 # Mouse/touch fallback: touch arrives as MouseButton.LEFT on web.
128 InputMap.add_action("toggle_refract", [Key.SPACE, MouseButton.LEFT])
129
130 def _update_camera(self):
131 rad = math.radians(self._cam_angle)
132 x = math.cos(rad) * self._cam_dist
133 z = math.sin(rad) * self._cam_dist
134 self.camera.position = Vec3(x, self._cam_height, z)
135 self.camera.look_at(Vec3(0, 0, 0))
136
137 def on_fixed_update(self, dt: float):
138 speed = 45.0
139 if Input.is_action_pressed("cam_left"):
140 self._cam_angle += speed * dt
141 if Input.is_action_pressed("cam_right"):
142 self._cam_angle -= speed * dt
143 if Input.is_action_pressed("cam_fwd"):
144 self._cam_dist = max(4.0, self._cam_dist - 8 * dt)
145 if Input.is_action_pressed("cam_back"):
146 self._cam_dist = min(14.0, self._cam_dist + 8 * dt)
147 self._update_camera()
148
149 def on_update(self, dt: float):
150 # Keep the controls hint pinned to the live window's bottom edge.
151 self._hint.position = (10, self.tree.screen_size[1] - 30)
152 if Input.is_action_just_pressed("toggle_refract"):
153 self._refract_on = not self._refract_on
154 # Flipping needs_scene_colour on the live material sets/clears the
155 # screen-read bit, so the next frame records with/without the split.
156 self._glass_mat.needs_scene_colour = self._refract_on
157 self._status.text = f"Refraction: {'ON' if self._refract_on else 'OFF'}"
158
159
160def main():
161 App(title="SimVX Screen-Space Refraction", width=WIDTH, height=HEIGHT).run(RefractionProbe())
162
163
164if __name__ == "__main__":
165 main()