Screen-space refraction

a glass ball bends the scene behind it.

▶ Run in browser

Tags: 3d

A transparent material that reads the screen draws what is behind it, bent:

glass = Material(colour=(0.75, 0.87, 1.0, 0.55), blend="alpha", needs_scene_colour=True)

needs_scene_colour=True makes the renderer split the frame in two. The opaque objects draw first, the colour they produced is copied into a texture, and then the transparent objects draw, each screen-reading one sampling that copy at its own pixel shifted by its surface normal. Where the normal turns, the sample shifts, so a curved surface bends the image behind it; a flat face seen head on has nothing to bend. The material’s alpha is how much of that bent scene shows through the glass’s own lit colour.

The split costs nothing when unused: a frame with no screen-reading material renders exactly as it would without the feature. WaterSurface3D builds its water shading on the same scene-colour copy (see water.py).

Here a glass ball sits in front of a grid of coloured cubes. Turning refraction off clears needs_scene_colour on the live material, and the ball becomes plain tinted glass that shows the grid straight through. The camera is an OrbitCamera3D: drag to orbit and scroll to zoom, or use the keys.

Controls: Space / button - Toggle refraction A / D / drag - Orbit the camera (arrow keys too) W / S / scroll - Zoom in / out (arrow keys too)

Source

  1# /// simvx
  2# screenshot_frame = 30
  3# web = { width = 960, height = 720, responsive = true }
  4# ///
  5"""Screen-space refraction: a glass ball bends the scene behind it.
  6
  7A transparent material that reads the screen draws what is behind it, bent:
  8
  9    glass = Material(colour=(0.75, 0.87, 1.0, 0.55), blend="alpha", needs_scene_colour=True)
 10
 11``needs_scene_colour=True`` makes the renderer split the frame in two. The
 12opaque objects draw first, the colour they produced is copied into a texture,
 13and then the transparent objects draw, each screen-reading one sampling that
 14copy at its own pixel shifted by its surface normal. Where the normal turns,
 15the sample shifts, so a curved surface bends the image behind it; a flat face
 16seen head on has nothing to bend. The material's alpha is how much of that
 17bent scene shows through the glass's own lit colour.
 18
 19The split costs nothing when unused: a frame with no screen-reading material
 20renders exactly as it would without the feature. ``WaterSurface3D`` builds
 21its water shading on the same scene-colour copy (see ``water.py``).
 22
 23Here a glass ball sits in front of a grid of coloured cubes. Turning
 24refraction off clears ``needs_scene_colour`` on the live material, and the ball
 25becomes plain tinted glass that shows the grid straight through. The camera is
 26an ``OrbitCamera3D``: drag to orbit and scroll to zoom, or use the keys.
 27
 28Controls:
 29    Space / button   - Toggle refraction
 30    A / D / drag     - Orbit the camera (arrow keys too)
 31    W / S / scroll   - Zoom in / out (arrow keys too)
 32"""
 33
 34import math
 35
 36from simvx.core import (
 37    Anchor,
 38    Axis1D,
 39    Button,
 40    CanvasLayer,
 41    DirectionalLight3D,
 42    Input,
 43    Key,
 44    Label,
 45    Material,
 46    Mesh,
 47    MeshInstance3D,
 48    Node3D,
 49    OrbitCamera3D,
 50    PointLight3D,
 51    WorldEnvironment,
 52    on_input,
 53)
 54from simvx.graphics import App
 55
 56GRID_COLOURS = [
 57    (0.90, 0.20, 0.20),
 58    (0.20, 0.85, 0.30),
 59    (0.20, 0.45, 0.95),
 60    (0.95, 0.80, 0.20),
 61    (0.85, 0.30, 0.85),
 62    (0.25, 0.85, 0.85),
 63]
 64
 65
 66class Refraction(Node3D):
 67    input_context = {
 68        "refraction": [Key.SPACE],
 69        "orbit": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT)],
 70        "zoom": [Axis1D(low=Key.S, high=Key.W), Axis1D(low=Key.DOWN, high=Key.UP)],
 71    }
 72
 73    def on_ready(self):
 74        self.camera = self.add_child(
 75            OrbitCamera3D(yaw=0.0, pitch=math.radians(-4), distance=7, min_distance=4, max_distance=14, fov=55)
 76        )
 77        self.add_child(DirectionalLight3D(colour=(1.0, 0.96, 0.9), intensity=1.6, direction=(-0.4, -0.7, -0.6)))
 78        self.add_child(PointLight3D(colour=(0.5, 0.6, 0.9), intensity=0.6, range=20.0, position=(-4, 3, 5)))
 79        self.add_child(WorldEnvironment(bloom_enabled=True))
 80
 81        # The opaque grid behind the glass: what the refraction reads.
 82        cube = Mesh.box(1.2)
 83        grid = [(x, y) for y in (1.4, 0.0, -1.4) for x in (-2.0, 0.0, 2.0)]
 84        for i, (x, y) in enumerate(grid):
 85            material = Material(colour=GRID_COLOURS[i % len(GRID_COLOURS)], roughness=0.55)
 86            self.add_child(MeshInstance3D(mesh=cube, material=material, position=(x, y, -1.2)))
 87
 88        self.glass = Material(colour=(0.75, 0.87, 1.0, 0.55), blend="alpha", roughness=0.05, needs_scene_colour=True)
 89        ball = Mesh.sphere(radius=1.5, rings=32, segments=32)
 90        self.add_child(MeshInstance3D(mesh=ball, material=self.glass, position=(0, 0, 0.8)))
 91
 92        hud = self.add_child(CanvasLayer())
 93        self.button = hud.add_child(
 94            Button("Refraction", action="refraction", checked=True, anchor=Anchor.TOP_LEFT, inset=12)
 95        )
 96
 97        # Example help overlay: not part of the technique shown.
 98        help_text = "Space: refraction   A/D / drag: orbit   W/S / scroll: zoom"
 99        hud.add_child(Label(help_text, anchor=Anchor.BOTTOM_LEFT, inset=12, font_size=14))
100
101    @on_input("refraction")
102    def toggle_refraction(self, _event):
103        # The next frame renders with or without the scene-colour copy.
104        self.glass.needs_scene_colour = not self.glass.needs_scene_colour
105        self.button.checked = self.glass.needs_scene_colour
106
107    def on_update(self, dt):
108        self.camera.orbit(Input.value("orbit") * math.radians(45) * dt, 0.0)
109        self.camera.zoom(Input.value("zoom") * 8.0 * dt)
110
111
112if __name__ == "__main__":
113    App(title="Screen-Space Refraction", width=960, height=720).run(Refraction())