Screen-space refraction

a glass slab reads the scene behind it and bends it.

▶ Run in browser

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