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