Screen-Space Ambient Occlusion

contact shadows where surfaces meet.

▶ Run in browser

Tags: 3d

Ambient light reaches a corner, a crevice or the ground under a ball less easily than an open wall. Screen-space ambient occlusion (SSAO) estimates this after the scene is drawn: for each pixel it samples the depth buffer in a small hemisphere around the surface and darkens the shaded colour by how much nearby geometry blocks it. The result is the soft darkening that makes objects sit on the ground instead of floating over it.

SSAO is a post-process, switched on by the scene’s WorldEnvironment:

env = WorldEnvironment(ssao_enabled=True, ssao_normals=True)
  • ssao_enabled turns the pass on; it is off by default.

  • ssao_normals has the renderer write real surface normals into a thin G-buffer for SSAO to read, instead of reconstructing them from depth, which removes dark fringes along silhouettes.

  • ssao_radius (world units), ssao_intensity and ssao_bias tune how far the samples reach, how dark the result gets and how much self-shadowing is suppressed on flat surfaces.

The scene is a courtyard built to occlude: walls meeting the ground, columns with bases and capitals, a stack of boxes in a corner, balls resting on the floor, an archway and a flight of steps. Toggle SSAO to see every contact lose its shadow. The OrbitCamera3D turns slowly on its own until the player first moves it; toggling SSAO leaves it turning. The button is Button(action="ssao"), which presses the same action as Space, so one @on_input("ssao") handler serves both.

Controls: Space / button - Toggle SSAO A / D / drag - Orbit the camera (left / right arrows too) W / S / scroll - Zoom in / out (up / down arrows too) Q / E - Raise / lower the camera

Source

  1"""Screen-Space Ambient Occlusion: contact shadows where surfaces meet.
  2
  3Ambient light reaches a corner, a crevice or the ground under a ball less
  4easily than an open wall. Screen-space ambient occlusion (SSAO) estimates this
  5after the scene is drawn: for each pixel it samples the depth buffer in a small
  6hemisphere around the surface and darkens the shaded colour by how much nearby
  7geometry blocks it. The result is the soft darkening that makes objects sit on
  8the ground instead of floating over it.
  9
 10SSAO is a post-process, switched on by the scene's ``WorldEnvironment``:
 11
 12    env = WorldEnvironment(ssao_enabled=True, ssao_normals=True)
 13
 14- ``ssao_enabled`` turns the pass on; it is off by default.
 15- ``ssao_normals`` has the renderer write real surface normals into a thin
 16  G-buffer for SSAO to read, instead of reconstructing them from depth, which
 17  removes dark fringes along silhouettes.
 18- ``ssao_radius`` (world units), ``ssao_intensity`` and ``ssao_bias`` tune how
 19  far the samples reach, how dark the result gets and how much self-shadowing
 20  is suppressed on flat surfaces.
 21
 22The scene is a courtyard built to occlude: walls meeting the ground, columns
 23with bases and capitals, a stack of boxes in a corner, balls resting on the
 24floor, an archway and a flight of steps. Toggle SSAO to see every contact
 25lose its shadow. The ``OrbitCamera3D`` turns slowly on its own until the
 26player first moves it; toggling SSAO leaves it turning. The button is
 27``Button(action="ssao")``, which presses the same action as Space, so one
 28``@on_input("ssao")`` handler serves both.
 29
 30Controls:
 31    Space / button     - Toggle SSAO
 32    A / D / drag       - Orbit the camera (left / right arrows too)
 33    W / S / scroll     - Zoom in / out (up / down arrows too)
 34    Q / E              - Raise / lower the camera
 35"""
 36
 37import math
 38
 39from simvx.core import (
 40    Anchor,
 41    Axis1D,
 42    Button,
 43    CanvasLayer,
 44    DirectionalLight3D,
 45    Input,
 46    Key,
 47    KeyEvent,
 48    Label,
 49    Material,
 50    Mesh,
 51    MeshInstance3D,
 52    Node3D,
 53    OrbitCamera3D,
 54    PointerEvent,
 55    PointerPhase,
 56    PointLight3D,
 57    ScrollEvent,
 58    WorldEnvironment,
 59    on_input,
 60)
 61from simvx.graphics import App
 62
 63KEY_ORBIT_SPEED = math.radians(40)  # radians per second
 64ZOOM_SPEED = 10.0  # world units per second
 65IDLE_SPIN = math.radians(4)
 66
 67
 68class SSAODemo(Node3D):
 69    input_context = {
 70        "ssao": [Key.SPACE],
 71        "orbit": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT)],
 72        "zoom": [Axis1D(low=Key.S, high=Key.W), Axis1D(low=Key.DOWN, high=Key.UP)],
 73        "tilt": [Axis1D(low=Key.Q, high=Key.E)],
 74    }
 75
 76    def on_ready(self):
 77        self.idle = True
 78        self.env = self.add_child(WorldEnvironment(ssao_enabled=True, ssao_normals=True, bloom_enabled=True))
 79        self.camera = self.add_child(
 80            OrbitCamera3D(
 81                pivot=(0, 2.5, -2),
 82                distance=20,
 83                yaw=math.radians(55),
 84                pitch=math.radians(-16),
 85                min_distance=8,
 86                max_distance=35,
 87                min_pitch=math.radians(-60),
 88                max_pitch=math.radians(-2),
 89                fov=55,
 90            )
 91        )
 92        self.add_child(DirectionalLight3D(colour=(1.0, 0.95, 0.85), intensity=1.2, direction=(0.32, -0.77, -0.56)))
 93        self.add_child(PointLight3D(colour=(0.4, 0.5, 0.8), intensity=0.6, range=25.0, position=(6, 6, 6)))
 94        self.build_courtyard()
 95
 96        hud = self.add_child(CanvasLayer())
 97        self.button = hud.add_child(Button("SSAO", action="ssao", checked=True, anchor=Anchor.TOP_LEFT, inset=12))
 98
 99        # Example help overlay: not part of the technique shown.
100        help_text = "Space: SSAO   A/D / drag: orbit   W/S / scroll: zoom   Q/E: raise / lower"
101        hud.add_child(Label(help_text, anchor=Anchor.BOTTOM_LEFT, inset=12, font_size=14))
102
103    def build_courtyard(self):
104        """Walls, columns, a box stack, balls, an arch and steps: every one a place where surfaces meet."""
105        wall = Material(colour=(0.7, 0.68, 0.65), roughness=0.85)
106        stone = Material(colour=(0.6, 0.58, 0.55), metallic=0.1, roughness=0.7)
107        dark = Material(colour=(0.35, 0.33, 0.30), roughness=0.95)
108        orange = Material(colour=(0.85, 0.4, 0.15), metallic=0.3, roughness=0.4)
109        chrome = Material(colour=(0.8, 0.82, 0.85), metallic=0.9, roughness=0.1)
110
111        def add(mesh, material, position):
112            self.add_child(MeshInstance3D(mesh=mesh, material=material, position=position))
113
114        add(Mesh.box((20, 0.1, 20)), Material(colour=(0.3, 0.3, 0.32), roughness=0.9), (0, -0.05, 0))
115        add(Mesh.box((12, 6, 0.3)), wall, (0, 3, -6))
116        for x in (-6, 6):
117            add(Mesh.box((0.3, 6, 6)), wall, (x, 3, -3))
118
119        column, block = Mesh.cylinder(0.25, 5.5, segments=16), Mesh.box((0.7, 0.3, 0.7))
120        for x in (-4, -2, 0, 2, 4):
121            add(column, stone, (x, 2.75, -5.5))
122            add(block, dark, (x, 0.15, -5.5))
123            add(block, dark, (x, 5.35, -5.5))
124
125        crate = Mesh.box(0.9)
126        stack = [
127            (-4.5, 0.5, -4.5),
128            (-4.5, 1.5, -4.5),
129            (-3.5, 0.5, -4.5),
130            (-4.5, 0.5, -3.5),
131            (-4, 0.5, -4),
132            (-4, 1.5, -4),
133        ]
134        for i, position in enumerate(stack):
135            add(crate, orange if i % 3 == 0 else dark, position)
136
137        for i in range(5):
138            radius = 0.4 + (i % 3) * 0.15
139            add(
140                Mesh.sphere(radius, rings=16, segments=24), chrome if i % 2 == 0 else orange, (-2 + i * 1.5, radius, -2)
141            )
142
143        arch_column = Mesh.cylinder(0.3, 4.0, segments=16)
144        for x in (-1.5, 1.5):
145            add(arch_column, stone, (x, 2, 0))
146        add(Mesh.box((3.6, 0.4, 0.6)), wall, (0, 4.2, 0))
147
148        step = Mesh.box((2, 0.3, 0.8))
149        for i in range(4):
150            add(step, dark, (4, 0.15 + i * 0.3, -3 + i * 0.8))
151
152    @on_input("ssao")
153    def toggle_ssao(self, _event):
154        self.env.ssao_enabled = not self.env.ssao_enabled
155        self.button.checked = self.env.ssao_enabled
156        return True
157
158    @on_input
159    def take_camera(self, event):
160        # The first key, press or scroll nothing claimed hands the camera to the player.
161        # The SSAO toggle claims its press, so the view keeps turning while the reader compares.
162        match event:
163            case KeyEvent(pressed=True) | PointerEvent(phase=PointerPhase.DOWN) | ScrollEvent():
164                self.idle = False
165
166    def on_update(self, dt):
167        spin = -IDLE_SPIN if self.idle else 0.0
168        self.camera.orbit(
169            (Input.value("orbit") * KEY_ORBIT_SPEED + spin) * dt, Input.value("tilt") * KEY_ORBIT_SPEED * dt
170        )
171        self.camera.zoom(Input.value("zoom") * ZOOM_SPEED * dt)
172
173
174if __name__ == "__main__":
175    App(title="SSAO", width=1280, height=720).run(SSAODemo())