SSAO Demo

Screen-Space Ambient Occlusion visualization.

▶ Run in browser

Tags: 3d

Demonstrates:

  • SSAO effect on a scene with many objects (columns, walls, corners)

  • Toggle SSAO on/off with Space key to see the difference

  • PBR materials with ambient occlusion darkening in crevices

  • Post-processing (HDR + bloom + SSAO)

Run: uv run python examples/features/3d/ssao.py

Controls: Space / Tap - Toggle SSAO on/off Drag - Orbit camera (mouse or touch) A / D - Orbit camera left / right W / S - Zoom in / out Q / E - Raise / lower camera

Source

  1"""
  2SSAO Demo: Screen-Space Ambient Occlusion visualization.
  3
  4Demonstrates:
  5  - SSAO effect on a scene with many objects (columns, walls, corners)
  6  - Toggle SSAO on/off with Space key to see the difference
  7  - PBR materials with ambient occlusion darkening in crevices
  8  - Post-processing (HDR + bloom + SSAO)
  9
 10Run: uv run python examples/features/3d/ssao.py
 11
 12Controls:
 13    Space / Tap - Toggle SSAO on/off
 14    Drag        - Orbit camera (mouse or touch)
 15    A / D       - Orbit camera left / right
 16    W / S       - Zoom in / out
 17    Q / E       - Raise / lower camera
 18"""
 19
 20
 21import math
 22
 23from simvx.core import (
 24    Camera3D,
 25    DirectionalLight3D,
 26    Input,
 27    InputMap,
 28    Key,
 29    Material,
 30    Mesh,
 31    MeshInstance3D,
 32    MouseButton,
 33    Node3D,
 34    PointLight3D,
 35    Quat,
 36    Text2D,
 37    Vec3,
 38    WorldEnvironment,
 39)
 40from simvx.graphics import App
 41
 42WIDTH, HEIGHT = 1280, 720
 43
 44
 45class SSAOScene(Node3D):
 46    def __init__(self, **kwargs):
 47        super().__init__(name="SSAODemo", **kwargs)
 48
 49        # Camera
 50        self._cam_angle = 30.0
 51        self._cam_height = 8.0
 52        self._cam_dist = 18.0
 53        self._auto_orbit = True  # gentle idle spin until the first camera input
 54        self._drag_distance = 0.0  # accumulated pointer travel for tap-vs-drag
 55        self.camera = self.add_child(
 56            Camera3D(
 57                name="Camera",
 58                fov=55,
 59                near=0.1,
 60                far=100.0,
 61            )
 62        )
 63        self._update_camera()
 64
 65        # Lighting
 66        sun = self.add_child(DirectionalLight3D(name="Sun"))
 67        sun.colour = (1.0, 0.95, 0.85)
 68        sun.intensity = 1.2
 69        sun.rotation = Quat.from_euler(math.radians(-50), math.radians(-30), 0)
 70
 71        fill = self.add_child(
 72            PointLight3D(
 73                name="Fill",
 74                position=Vec3(6, 6, 6),
 75            )
 76        )
 77        fill.colour = (0.4, 0.5, 0.8)
 78        fill.intensity = 0.6
 79        fill.range = 25.0
 80
 81        # Ground
 82        ground_mat = Material(colour=(0.3, 0.3, 0.32), metallic=0.0, roughness=0.9)
 83        self.add_child(
 84            MeshInstance3D(
 85                name="Ground",
 86                mesh=Mesh.cube(1.0),
 87                material=ground_mat,
 88                position=Vec3(0, -0.05, 0),
 89                scale=Vec3(20, 0.1, 20),
 90            )
 91        )
 92
 93        # Build a scene with many objects close together to show SSAO
 94        self._build_scene()
 95
 96        # SSAO state via WorldEnvironment
 97        self._ssao_on = True
 98        self._toggle_cooldown = 0.0
 99        self._env = self.add_child(WorldEnvironment(name="Env"))
100        self._env.ssao_enabled = True
101        # Thin G-buffer real normals (A9): SSAO reads the octahedral world normal
102        # from the second HDR attachment instead of reconstructing it from depth,
103        # removing the silhouette artefacts of the derivative path.
104        self._env.ssao_normals = True
105        self._env.bloom_enabled = True
106
107        # HUD
108        self._title = self.add_child(
109            Text2D(
110                text="SSAO DEMO",
111                position=(10, 8), font_scale=1.6,
112            )
113        )
114        self._status = self.add_child(
115            Text2D(
116                text="SSAO: ON",
117                position=(10, 40), font_scale=1.3,
118            )
119        )
120        self._controls = self.add_child(
121            Text2D(
122                text="SPACE/TAP:Toggle SSAO  DRAG/WASD/QE:Camera",
123                position=(10, HEIGHT - 30), font_scale=1.1,
124            )
125        )
126
127    def on_ready(self):
128        InputMap.add_action("cam_left", [Key.A, Key.LEFT])
129        InputMap.add_action("cam_right", [Key.D, Key.RIGHT])
130        InputMap.add_action("cam_fwd", [Key.W, Key.UP])
131        InputMap.add_action("cam_back", [Key.S, Key.DOWN])
132        InputMap.add_action("cam_up", [Key.Q])
133        InputMap.add_action("cam_down", [Key.E])
134        InputMap.add_action("toggle_ssao", [Key.SPACE])
135
136    def _build_scene(self):
137        """Create columns, walls, and objects to demonstrate AO in crevices."""
138        wall_mat = Material(colour=(0.7, 0.68, 0.65), metallic=0.0, roughness=0.85)
139        column_mat = Material(colour=(0.6, 0.58, 0.55), metallic=0.1, roughness=0.7)
140        dark_mat = Material(colour=(0.35, 0.33, 0.30), metallic=0.0, roughness=0.95)
141        bright_mat = Material(colour=(0.85, 0.4, 0.15), metallic=0.3, roughness=0.4)
142        metal_mat = Material(colour=(0.8, 0.82, 0.85), metallic=0.9, roughness=0.1)
143
144        # Back wall
145        self.add_child(
146            MeshInstance3D(
147                name="BackWall",
148                mesh=Mesh.cube(1.0),
149                material=wall_mat,
150                position=Vec3(0, 3, -6),
151                scale=Vec3(12, 6, 0.3),
152            )
153        )
154
155        # Side walls
156        for i, x in enumerate([-6, 6]):
157            self.add_child(
158                MeshInstance3D(
159                    name=f"SideWall{i}",
160                    mesh=Mesh.cube(1.0),
161                    material=wall_mat,
162                    position=Vec3(x, 3, -3),
163                    scale=Vec3(0.3, 6, 6),
164                )
165            )
166
167        # Columns along back wall
168        for i, x in enumerate([-4, -2, 0, 2, 4]):
169            self.add_child(
170                MeshInstance3D(
171                    name=f"Column{i}",
172                    mesh=Mesh.cylinder(0.25, 5.5, segments=16),
173                    material=column_mat,
174                    position=Vec3(x, 2.75, -5.5),
175                )
176            )
177            # Column base
178            self.add_child(
179                MeshInstance3D(
180                    name=f"ColumnBase{i}",
181                    mesh=Mesh.cube(1.0),
182                    material=dark_mat,
183                    position=Vec3(x, 0.15, -5.5),
184                    scale=Vec3(0.7, 0.3, 0.7),
185                )
186            )
187            # Column capital
188            self.add_child(
189                MeshInstance3D(
190                    name=f"ColumnCap{i}",
191                    mesh=Mesh.cube(1.0),
192                    material=dark_mat,
193                    position=Vec3(x, 5.35, -5.5),
194                    scale=Vec3(0.7, 0.3, 0.7),
195                )
196            )
197
198        # Stacked boxes in corner (shows AO between adjacent surfaces)
199        box_positions = [
200            Vec3(-4.5, 0.5, -4.5),
201            Vec3(-4.5, 1.5, -4.5),
202            Vec3(-3.5, 0.5, -4.5),
203            Vec3(-4.5, 0.5, -3.5),
204            Vec3(-4.0, 0.5, -4.0),
205            Vec3(-4.0, 1.5, -4.0),
206        ]
207        for i, pos in enumerate(box_positions):
208            mat = bright_mat if i % 3 == 0 else dark_mat
209            self.add_child(
210                MeshInstance3D(
211                    name=f"StackedBox{i}",
212                    mesh=Mesh.cube(1.0),
213                    material=mat,
214                    position=pos,
215                    scale=Vec3(0.9, 0.9, 0.9),
216                )
217            )
218
219        # Spheres on the ground (shows AO at ground contact)
220        for i in range(5):
221            x = -2.0 + i * 1.5
222            r = 0.4 + (i % 3) * 0.15
223            mat = metal_mat if i % 2 == 0 else bright_mat
224            self.add_child(
225                MeshInstance3D(
226                    name=f"GroundSphere{i}",
227                    mesh=Mesh.sphere(r, rings=16, segments=24),
228                    material=mat,
229                    position=Vec3(x, r, -2.0),
230                )
231            )
232
233        # Archway (two columns + lintel)
234        for i, x in enumerate([-1.5, 1.5]):
235            self.add_child(
236                MeshInstance3D(
237                    name=f"ArchColumn{i}",
238                    mesh=Mesh.cylinder(0.3, 4.0, segments=16),
239                    material=column_mat,
240                    position=Vec3(x, 2.0, 0),
241                )
242            )
243        self.add_child(
244            MeshInstance3D(
245                name="ArchLintel",
246                mesh=Mesh.cube(1.0),
247                material=wall_mat,
248                position=Vec3(0, 4.2, 0),
249                scale=Vec3(3.6, 0.4, 0.6),
250            )
251        )
252
253        # Stepped platform (shows AO on step edges)
254        for i in range(4):
255            self.add_child(
256                MeshInstance3D(
257                    name=f"Step{i}",
258                    mesh=Mesh.cube(1.0),
259                    material=dark_mat,
260                    position=Vec3(4.0, 0.15 + i * 0.3, -3.0 + i * 0.8),
261                    scale=Vec3(2.0, 0.3, 0.8),
262                )
263            )
264
265    def _update_camera(self):
266        rad = math.radians(self._cam_angle)
267        x = math.cos(rad) * self._cam_dist
268        z = math.sin(rad) * self._cam_dist
269        self.camera.position = Vec3(x, self._cam_height, z)
270        self.camera.look_at(Vec3(0, 2.5, -2))
271
272    def on_fixed_update(self, dt: float):
273        speed = 40.0
274        if Input.is_action_pressed("cam_left"):
275            self._cam_angle += speed * dt
276            self._auto_orbit = False
277        if Input.is_action_pressed("cam_right"):
278            self._cam_angle -= speed * dt
279            self._auto_orbit = False
280        if Input.is_action_pressed("cam_fwd"):
281            self._cam_dist = max(8, self._cam_dist - 10 * dt)
282            self._auto_orbit = False
283        if Input.is_action_pressed("cam_back"):
284            self._cam_dist = min(35, self._cam_dist + 10 * dt)
285            self._auto_orbit = False
286        if Input.is_action_pressed("cam_up"):
287            self._cam_height = min(20, self._cam_height + 6 * dt)
288            self._auto_orbit = False
289        if Input.is_action_pressed("cam_down"):
290            self._cam_height = max(2, self._cam_height - 6 * dt)
291            self._auto_orbit = False
292        # Gentle idle spin until the first camera input so the AO crevice
293        # darkening reads immediately even before the user touches anything.
294        if self._auto_orbit:
295            self._cam_angle += 4.0 * dt
296        self._update_camera()
297
298    def _toggle_ssao(self):
299        if self._toggle_cooldown > 0:
300            return
301        self._ssao_on = not self._ssao_on
302        self._toggle_cooldown = 0.3
303        self._env.ssao_enabled = self._ssao_on
304
305    def on_update(self, dt: float):
306        # Toggle SSAO with cooldown
307        self._toggle_cooldown = max(0, self._toggle_cooldown - dt)
308        if Input.is_action_just_pressed("toggle_ssao"):
309            self._toggle_ssao()
310
311        # Mouse / touch: drag orbits the camera, a short tap (press + release
312        # with little travel) toggles SSAO. Touch arrives as MouseButton.LEFT
313        # on web, so this keeps the demo playable on phones and tablets.
314        if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
315            self._drag_distance = 0.0
316        if Input.is_mouse_button_pressed(MouseButton.LEFT):
317            delta = Input.mouse_delta
318            dx, dy = float(delta.x), float(delta.y)
319            self._drag_distance += abs(dx) + abs(dy)
320            if self._drag_distance > 6.0:
321                self._auto_orbit = False
322                self._cam_angle -= dx * 0.3
323                self._cam_height = max(2, min(20, self._cam_height + dy * 0.05))
324        if Input.is_mouse_button_just_released(MouseButton.LEFT) and self._drag_distance <= 6.0:
325            self._toggle_ssao()
326
327        self._status.text = f"SSAO: {'ON' if self._ssao_on else 'OFF'}"
328        # Pin the controls hint to the live viewport bottom (resize-aware).
329        self._controls.position = (10, self.app.height - 30)
330
331
332def main():
333    scene = SSAOScene()
334    app = App(title="SimVX SSAO Demo", width=WIDTH, height=HEIGHT, physics_fps=60)
335    app.run(scene)
336
337
338if __name__ == "__main__":
339    main()